5

Inferring C# from JSON with T4

 2 years ago
source link: https://gist.github.com/mrange/7486ba2d2819e519d4b7307ac3524547
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
Inferring C# from JSON with T4

C# from JSON in T4

A microblog with minimal prose and maximum amount of code

Inspired by the release notes in VS about "copy JSON to C#" I tinkered with making a T4 template to do so.

The T4 template uses some new C# language feautures + System.Text.Json so it won't work with T4 bundled with Visual Studio (still on .NET framework).

However it works fine from command line:

How to run

  1. Save 1_Sample.tt as Sample.tt
  2. Save 3_JsonSchemaInferrer.ttinclude as JsonSchemaInferrer.ttinclude
  3. Install t4 by running: dotnet tool install -g dotnet-t4
  4. Generate Sample.cs by running: t4 Sample.tt

F# is great for these kind of things

While F# don't work out in T4 I used F# to create the initial schema inferrer, this I later ported to C#.

See: 4_InferSchema.fs

<# // The namespace for the generated classes var Namespace = "MyNamespace" ; // Name of the outer class // The other class names are inferred from the member name // This approach is not fool proof but good enough to start with var RootClass = "Customer" ; // Sample JSON var Json = """ { "firstName": "John", "lastName": "Smith", "age": 25, "address": { "streetAddress": "21 2nd Street", "city": "New York", "state": "NY", "postalCode": "10021" }, "phoneNumber": [ { "type": "home", "number": "212 555-1234" }, { "type": "fax", "number": "646 555-4567" } ] } """;

// Include the inferrer code to generate the C# code #> <#@ include file="JsonSchemaInferrer.ttinclude" #>

// ----------------------------------------------------------------------------- // THIS FILE IS AUTO-GENERATED, EDITS IN THE FILE WILL BE LOST ON REGENERATION // -----------------------------------------------------------------------------

namespace MyNamespace; using System.Text.Json.Serialization;

// ----------------------------------------------------------------------------- // BEGIN - Address // ----------------------------------------------------------------------------- partial class Address { [JsonPropertyNameAttribute("city")] public string City { get; set; }

[JsonPropertyNameAttribute("postalCode")] public string PostalCode { get; set; }

[JsonPropertyNameAttribute("state")] public string State { get; set; }

[JsonPropertyNameAttribute("streetAddress")] public string StreetAddress { get; set; }

} // ----------------------------------------------------------------------------- // END - Address // -----------------------------------------------------------------------------

// ----------------------------------------------------------------------------- // BEGIN - Customer // ----------------------------------------------------------------------------- partial class Customer { [JsonPropertyNameAttribute("address")] public Address Address { get; set; }

[JsonPropertyNameAttribute("age")] public int Age { get; set; }

[JsonPropertyNameAttribute("firstName")] public string FirstName { get; set; }

[JsonPropertyNameAttribute("lastName")] public string LastName { get; set; }

[JsonPropertyNameAttribute("phoneNumber")] public PhoneNumber[] PhoneNumber { get; set; }

} // ----------------------------------------------------------------------------- // END - Customer // -----------------------------------------------------------------------------

// ----------------------------------------------------------------------------- // BEGIN - PhoneNumber // ----------------------------------------------------------------------------- partial class PhoneNumber { [JsonPropertyNameAttribute("number")] public string Number { get; set; }

[JsonPropertyNameAttribute("type")] public string Type { get; set; }

} // ----------------------------------------------------------------------------- // END - PhoneNumber // -----------------------------------------------------------------------------

<#@ template langversion="preview" #> <#@ import namespace="System.Collections.Generic" #> <#@ import namespace="System.Linq" #> <#@ import namespace="System.Text" #> <#@ import namespace="System.Text.Json" #> <#@ output extension=".cs" #> // ----------------------------------------------------------------------------- // THIS FILE IS AUTO-GENERATED, EDITS IN THE FILE WILL BE LOST ON REGENERATION // -----------------------------------------------------------------------------

namespace <#=Namespace#>; using System.Text.Json.Serialization;

<# var schema = InferredSchema.Undefined.Infer(Json); var objects = GetObjects(new (), RootClass, schema); #> <# foreach (var kv in objects.OrderBy(kv => kv.Value)) { #> <# var className = ToCSharpName(kv.Value); var clazz = kv.Key; #> // ----------------------------------------------------------------------------- // BEGIN - <#=className#> // ----------------------------------------------------------------------------- partial class <#=className#> { <# foreach (var m in clazz.Members.OrderBy(kv => kv.Key)) { #> [JsonPropertyNameAttribute("<#=m.Key#>")] public <#=ToCSharpType(objects, m.Value)#> <#=ToCSharpName(m.Key)#> { get; set; }

<# } #> } // ----------------------------------------------------------------------------- // END - <#=className#> // -----------------------------------------------------------------------------

<# } #>

<#+ static string ToCSharpType( Dictionary<InferredObjectSchema, string> objects , InferredSchema s ) { static string ToType( Dictionary<InferredObjectSchema, string> objects , InferredSchema s ) { if (s.HasBooleanValues) return "bool"; if (s.HasNumberValues is not null) { return s.HasNumberValues switch { NumberKind.Float => "double" , NumberKind.Integer => "int" , var k => throw new Exception($"Unexpected IntegerKind: {k}") }; } if (s.HasStringValues) return "string"; if (s.HasObjectValues is not null) return ToCSharpName(objects[s.HasObjectValues]); if (s.HasArrayValues is not null) return ToType(objects, s.HasArrayValues) + "[]";

return "object"; }

switch(s.Kind) { case InferredSchemaKind.Empty: return "object"; case InferredSchemaKind.Singleton: var t = ToType(objects, s); return s.HasNullValues ? t + "?" : t; case InferredSchemaKind.Union: throw new Exception($"Discriminated Union types not supported yet"); case var k: throw new Exception($"Unexpected InferredSchemaKind: {k}"); } }

static string ToCSharpName(string nm) { if (string.IsNullOrWhiteSpace(nm)) return "<EMPTY NAME>"; var c = char.ToUpperInvariant(nm[0]); return c + nm.Substring(1); }

static Dictionary<InferredObjectSchema, string> GetObjects( Dictionary<InferredObjectSchema, string> objects , string key , InferredSchema value ) { switch(value.Kind) { case InferredSchemaKind.Empty: break; case InferredSchemaKind.Singleton: var a = value.HasArrayValues; if (a is not null) { GetObjects(objects, key, a); } var o = value.HasObjectValues; if (o is not null) { objects.Add(o, key); foreach (var m in o.Members) { GetObjects(objects, m.Key, m.Value); } } break; case InferredSchemaKind.Union: throw new Exception($"Discriminated Union types not supported yet: {key}"); case var k: throw new Exception($"Unexpected InferredSchemaKind: {k}"); } return objects; }

enum NumberKind { Float , Integer , }

enum InferredSchemaKind { Empty , Singleton , Union , }

sealed record InferredObjectSchema( Dictionary<string, InferredSchema> Members ) { public bool Equals( InferredObjectSchema? o ) { if (o is null) return false;

if (Members.Count != o.Members.Count) return false;

foreach (var m in Members) { if (o.Members.TryGetValue(m.Key, out var v)) { if (!m.Value.Equals(v)) { return false; } } else { return false; } } return true; }

public override int GetHashCode() { unchecked { var hc = 0x55555555; foreach (var m in Members) { hc += m.Key.GetHashCode(); hc += m.Value.GetHashCode(); } return hc; } }

public override string ToString() { var sb = new StringBuilder("InferredObjectSchema {"); var prefix = ""; foreach (var m in Members.OrderBy(kv => kv.Key)) { sb .Append(prefix) .Append(" '") .Append(m.Key) .Append("' : ") .Append(m.Value) ; prefix = ","; } sb.Append("}"); return sb.ToString(); } }

sealed record InferredSchema( bool HasNullValues , bool HasBooleanValues , NumberKind? HasNumberValues , bool HasStringValues , InferredObjectSchema? HasObjectValues , InferredSchema? HasArrayValues ) { public InferredSchemaKind Kind { get { static int AsInt(bool b) => b ? 1 : 0; var c = 0 + AsInt(HasNullValues) + AsInt(HasBooleanValues) + AsInt(HasNumberValues is not null) + AsInt(HasStringValues) + AsInt(HasObjectValues is not null) + AsInt(HasArrayValues is not null) ; return c switch { 0 => InferredSchemaKind.Empty , 1 => InferredSchemaKind.Singleton , _ => InferredSchemaKind.Union }; } }

public static InferredSchema Undefined = new InferredSchema( false , false , null , false , null , null );

public static InferredSchema Nullable = Undefined with { HasNullValues = true }; public static InferredSchema Boolean = Undefined with { HasBooleanValues = true }; public static InferredSchema Integer = Undefined with { HasNumberValues = NumberKind.Integer }; public static InferredSchema Float = Undefined with { HasNumberValues = NumberKind.Float }; public static InferredSchema String = Undefined with { HasStringValues = true };

public static InferredSchema Object(InferredObjectSchema s) => Undefined with { HasObjectValues = s }; public static InferredSchema Array(InferredSchema s) => Undefined with { HasArrayValues = s };

static InferredSchema GetMember( InferredObjectSchema c , string nm ) => c.Members.TryGetValue(nm, out var v) ? v : Nullable;

static InferredObjectSchema MergeObjects( InferredObjectSchema c , InferredObjectSchema o ) => new InferredObjectSchema(c.Members.Keys.Union(o.Members.Keys).ToDictionary( k => k , k => GetMember(c, k).MergeWith(GetMember(o, k)) ));

public InferredSchema MergeWith(InferredSchema o) { var hasNumberValues = (HasNumberValues, o.HasNumberValues) switch { (null , null ) => null , (null , var oo ) => oo , (var cc , null ) => cc , (NumberKind.Integer , NumberKind.Integer ) => NumberKind.Integer , (_ , _ ) => NumberKind.Float }; var hasObjectValues = (HasObjectValues, o.HasObjectValues) switch { (null , null ) => null , (null , var oo ) => oo , (var cc , null ) => cc , (var cc , var oo ) => MergeObjects(cc, oo) }; var hasArrayValues = (HasArrayValues, o.HasArrayValues) switch { (null , null ) => null , (null , var oo ) => oo , (var cc , null ) => cc , (var cc , var oo ) => cc.MergeWith(oo) }; return new InferredSchema( HasNullValues || o.HasNullValues , HasBooleanValues || o.HasBooleanValues , hasNumberValues , HasStringValues || o.HasStringValues , hasObjectValues , hasArrayValues ); }

public InferredSchema Infer(string json) => Infer(JsonDocument.Parse(json).RootElement);

public InferredSchema Infer(JsonElement e) { switch(e.ValueKind) { case JsonValueKind.Undefined : return MergeWith(Undefined); case JsonValueKind.String : return MergeWith(String); case JsonValueKind.True : return MergeWith(Boolean); case JsonValueKind.False : return MergeWith(Boolean); case JsonValueKind.Null : return MergeWith(Nullable); case JsonValueKind.Number : { var d = e.GetDouble(); var a = d == Math.Floor(d) ? Integer : Float; return MergeWith(a); } case JsonValueKind.Object : { var a = new InferredObjectSchema(e .EnumerateObject() .ToDictionary( kv => kv.Name , kv => Undefined.Infer(kv.Value) )); return MergeWith(Object(a)); } case JsonValueKind.Array : { var a = e .EnumerateArray() .Aggregate(Undefined, (s,v) => s.Infer(v)) ; return MergeWith(Array(a)); } case var jkv : throw new Exception($"Unexpected JsonValueKind: {jkv}"); } }

} #>

module InferSchema = open System open System.Text.Json

// We infer numbers to be either integer or float [<RequireQualifiedAccess>] type NumberKind = Integer|Float

// The inferred schema can either be // Empty - No non-null values seen // Singleton - Seen one kind of non-null values values // Union - Seen several kind of non-null values [<RequireQualifiedAccess>] type InferredSchemaKind = Empty|Singleton|Union

// A union of all possible json value schemas type InferredSchema = { HasNullValues : bool HasBooleanValues : bool HasNumberValues : NumberKind option HasStringValues : bool HasObjectValues : Map<string, InferredSchema> option HasArrayValues : InferredSchema option } member x.Kind = let inc b s = if b then s + 1 else s let c = 0 // Null not considered to contribute union count |> inc x.HasBooleanValues |> inc x.HasNumberValues.IsSome |> inc x.HasStringValues |> inc x.HasObjectValues.IsSome |> inc x.HasArrayValues.IsSome match c with | 0 -> InferredSchemaKind.Empty | 1 -> InferredSchemaKind.Singleton | _ -> InferredSchemaKind.Union

module Schemas = let undefined : InferredSchema = { HasNullValues = false HasBooleanValues = false HasNumberValues = None HasStringValues = false HasObjectValues = None HasArrayValues = None } let nullable = { undefined with HasNullValues = true } let boolean = { undefined with HasBooleanValues = true } let integer = { undefined with HasNumberValues = Some NumberKind.Integer } let float = { undefined with HasNumberValues = Some NumberKind.Float } let string = { undefined with HasStringValues = true } let object s = { undefined with HasObjectValues = Some s } let array s = { undefined with HasArrayValues = Some s }

module Details = let inline mergeOptions a b ([<InlineIfLambda>] m) = match a, b with | _ , None -> a | None , _ -> b | Some a, Some b-> Some (m a b)

open Details

// merges two schemas let rec mergeSchemas (current : InferredSchema) (other : InferredSchema) : InferredSchema = let hasNumberValues = mergeOptions current.HasNumberValues other.HasNumberValues ( fun a b -> match (a, b) with // Int and Int is Int | NumberKind.Integer , NumberKind.Integer -> NumberKind.Integer // Other default to float | _ ,_ -> NumberKind.Float ) let hasObjectValues = mergeOptions current.HasObjectValues other.HasObjectValues ( fun c o -> let all = Set.union (c.Keys |> Set.ofSeq) (o.Keys |> Set.ofSeq) let inline getMember nm m= match m |> Map.tryFind nm with | Some v -> v // If the member don't exist we assume nullable // It makes sense because in order for this to happen // this member exists in the other schema // We therefore like the merged schema to be nullable | None -> Schemas.nullable let mapper nm = let ca = c |> getMember nm let oa = o |> getMember nm let a = mergeSchemas ca oa (nm, a) all |> Seq.map mapper |> Map.ofSeq ) let hasArrayValues = mergeOptions current.HasArrayValues other.HasArrayValues mergeSchemas

{ HasNullValues = current.HasNullValues || other.HasNullValues HasBooleanValues = current.HasBooleanValues || other.HasBooleanValues HasNumberValues = hasNumberValues HasStringValues = current.HasStringValues || other.HasStringValues HasObjectValues = hasObjectValues HasArrayValues = hasArrayValues }

// Infers a json schema from JsonElement let rec infer (current : InferredSchema) (json : JsonElement) : InferredSchema = // Traverses through the json element and infers the type // of the element then merge with current let inline mergeWith other = mergeSchemas current other match json.ValueKind with | JsonValueKind.Undefined -> mergeWith Schemas.undefined | JsonValueKind.Object -> let a = json.EnumerateObject () |> Seq.map (fun v -> (v.Name, infer Schemas.undefined v.Value)) |> Map.ofSeq mergeWith (Schemas.object a) | JsonValueKind.Array -> let a = json.EnumerateArray () |> Seq.fold infer Schemas.undefined mergeWith (Schemas.array a) | JsonValueKind.String -> mergeWith Schemas.string | JsonValueKind.Number -> let f = json.GetDouble() let a = if f = floor f then Schemas.integer else Schemas.float mergeWith a | JsonValueKind.True -> mergeWith Schemas.boolean | JsonValueKind.False -> mergeWith Schemas.boolean | JsonValueKind.Null -> mergeWith Schemas.nullable | jkv -> failwithf "Unexpected JsonValueKind: %A" jkv

let json = """ { "firstName": "John", "lastName": "Smith", "age": 25, "address": { "streetAddress": "21 2nd Street", "city": "New York", "state": "NY", "postalCode": "10021" }, "phoneNumber": [ { "type": "home", "number": "212 555-1234" }, { "type": "fax", "number": "646 555-4567" } ] } """

open System.Text.Json open InferSchema

[<EntryPoint>] let main args = let doc = JsonDocument.Parse json let schema = infer Schemas.undefined doc.RootElement printfn "%A" schema 0

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK