From 91b1b7cc3103af9e34ec7026baee7bd6987e40b2 Mon Sep 17 00:00:00 2001 From: Daniel Cazzulino Date: Mon, 25 Nov 2024 13:05:31 -0300 Subject: [PATCH] Add Newstonsoft.Json converter support This is the first template that truly showcases the dynamic nature of our approach: you only need to add a reference to the Newtonsoft.Json package, and you get the added support, without any additional configuration whatesoever. --- readme.md | 10 +- .../NJsonConverterGenerator.cs | 10 -- src/StructId.Analyzer/NewableGenerator.cs | 2 +- .../NewtonsoftJsonGenerator.cs | 30 +++++ .../StructId.Analyzer.csproj | 1 + ...enerator.cs => SystemTextJsonGenerator.cs} | 2 +- src/StructId.Analyzer/TemplateGenerator.cs | 21 ++-- src/StructId.FunctionalTests/Functional.cs | 10 ++ .../ConstructorGeneratorTests.cs | 3 + .../NewtonsoftJsonGeneratorTests.cs | 115 ++++++++++++++++++ src/StructId.Tests/RecordAnalyzerTests.cs | 9 ++ src/StructId.Tests/RecordCodeFixTests.cs | 3 + src/StructId.Tests/TestExtensions.cs | 10 +- ...onverter.cs => NewtonsoftJsonConverter.cs} | 0 ...verterT.cs => NewtonsoftJsonConverterT.cs} | 0 .../NewtonsoftJsonConverter`1.cs} | 0 16 files changed, 196 insertions(+), 30 deletions(-) delete mode 100644 src/StructId.Analyzer/NJsonConverterGenerator.cs create mode 100644 src/StructId.Analyzer/NewtonsoftJsonGenerator.cs rename src/StructId.Analyzer/{JsonConverterGenerator.cs => SystemTextJsonGenerator.cs} (79%) create mode 100644 src/StructId.Tests/NewtonsoftJsonGeneratorTests.cs rename src/StructId/Templates/{NJsonConverter.cs => NewtonsoftJsonConverter.cs} (100%) rename src/StructId/Templates/{NJsonConverterT.cs => NewtonsoftJsonConverterT.cs} (100%) rename src/StructId/{StructIdConverters.NJsonConverter.cs => Templates/NewtonsoftJsonConverter`1.cs} (100%) diff --git a/readme.md b/readme.md index e4d7d6b..12416e1 100644 --- a/readme.md +++ b/readme.md @@ -1,10 +1,10 @@ -![Icon](img/icon-32.png) ThisAssembly +![Icon](img/icon-32.png) StructId ============ -[![Version](https://img.shields.io/nuget/vpre/ThisAssembly.svg?color=royalblue)](https://www.nuget.org/packages/ThisAssembly) -[![Downloads](https://img.shields.io/nuget/dt/ThisAssembly.svg?color=green)](https://www.nuget.org/packages/ThisAssembly) -[![License](https://img.shields.io/github/license/devlooped/ThisAssembly.svg?color=blue)](https://github.com//devlooped/ThisAssembly/blob/main/license.txt) -[![Build](https://github.com/devlooped/ThisAssembly/workflows/build/badge.svg?branch=main)](https://github.com/devlooped/ThisAssembly/actions) +[![Version](https://img.shields.io/nuget/vpre/StructId.svg?color=royalblue)](https://www.nuget.org/packages/StructId) +[![Downloads](https://img.shields.io/nuget/dt/StructId.svg?color=green)](https://www.nuget.org/packages/StructId) +[![License](https://img.shields.io/github/license/devlooped/StructId.svg?color=blue)](https://github.com//devlooped/StructId/blob/main/license.txt) +[![Build](https://github.com/devlooped/StructId/workflows/build/badge.svg?branch=main)](https://github.com/devlooped/StructId/actions) An opinionated strongly-typed ID library that uses `readonly record struct` in C# for diff --git a/src/StructId.Analyzer/NJsonConverterGenerator.cs b/src/StructId.Analyzer/NJsonConverterGenerator.cs deleted file mode 100644 index 5b84331..0000000 --- a/src/StructId.Analyzer/NJsonConverterGenerator.cs +++ /dev/null @@ -1,10 +0,0 @@ -using Microsoft.CodeAnalysis; - -namespace StructId; - -[Generator(LanguageNames.CSharp)] -public class NJsonConverterGenerator() : TemplateGenerator( - "Newtonsoft.Json.JsonConverter", - ThisAssembly.Resources.Templates.NJsonConverter.Text, - ThisAssembly.Resources.Templates.NJsonConverterT.Text, - TypeCheck.TypeExists); \ No newline at end of file diff --git a/src/StructId.Analyzer/NewableGenerator.cs b/src/StructId.Analyzer/NewableGenerator.cs index 9b95812..5164be0 100644 --- a/src/StructId.Analyzer/NewableGenerator.cs +++ b/src/StructId.Analyzer/NewableGenerator.cs @@ -7,4 +7,4 @@ public class NewableGenerator() : TemplateGenerator( "System.Object", ThisAssembly.Resources.Templates.Newable.Text, ThisAssembly.Resources.Templates.NewableT.Text, - TypeCheck.TypeExists); \ No newline at end of file + ReferenceCheck.TypeExists); \ No newline at end of file diff --git a/src/StructId.Analyzer/NewtonsoftJsonGenerator.cs b/src/StructId.Analyzer/NewtonsoftJsonGenerator.cs new file mode 100644 index 0000000..e3866d9 --- /dev/null +++ b/src/StructId.Analyzer/NewtonsoftJsonGenerator.cs @@ -0,0 +1,30 @@ +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace StructId; + +[Generator(LanguageNames.CSharp)] +public class NewtonsoftJsonGenerator() : TemplateGenerator( + "Newtonsoft.Json.JsonConverter", + ThisAssembly.Resources.Templates.NewtonsoftJsonConverter.Text, + ThisAssembly.Resources.Templates.NewtonsoftJsonConverterT.Text, + ReferenceCheck.TypeExists) +{ + public override void Initialize(IncrementalGeneratorInitializationContext context) + { + base.Initialize(context); + + context.RegisterSourceOutput( + context.CompilationProvider + .Select((x, _) => x.GetTypeByMetadataName("Newtonsoft.Json.JsonConverter")), + (context, source) => + { + if (source == null) + return; + + context.AddSource("NewtonsoftJsonConverter.cs", SourceText.From( + ThisAssembly.Resources.Templates.NewtonsoftJsonConverter_1.Text, Encoding.UTF8)); + }); + } +} \ No newline at end of file diff --git a/src/StructId.Analyzer/StructId.Analyzer.csproj b/src/StructId.Analyzer/StructId.Analyzer.csproj index f682071..2b946bd 100644 --- a/src/StructId.Analyzer/StructId.Analyzer.csproj +++ b/src/StructId.Analyzer/StructId.Analyzer.csproj @@ -20,6 +20,7 @@ + diff --git a/src/StructId.Analyzer/JsonConverterGenerator.cs b/src/StructId.Analyzer/SystemTextJsonGenerator.cs similarity index 79% rename from src/StructId.Analyzer/JsonConverterGenerator.cs rename to src/StructId.Analyzer/SystemTextJsonGenerator.cs index 01925ed..9e8ddd0 100644 --- a/src/StructId.Analyzer/JsonConverterGenerator.cs +++ b/src/StructId.Analyzer/SystemTextJsonGenerator.cs @@ -3,7 +3,7 @@ namespace StructId; [Generator(LanguageNames.CSharp)] -public class JsonConverterGenerator() : TemplateGenerator( +public class SystemTextJsonGenerator() : TemplateGenerator( "System.IParsable`1", ThisAssembly.Resources.Templates.JsonConverter.Text, ThisAssembly.Resources.Templates.JsonConverterT.Text); \ No newline at end of file diff --git a/src/StructId.Analyzer/TemplateGenerator.cs b/src/StructId.Analyzer/TemplateGenerator.cs index e182f47..b5f0163 100644 --- a/src/StructId.Analyzer/TemplateGenerator.cs +++ b/src/StructId.Analyzer/TemplateGenerator.cs @@ -8,7 +8,7 @@ namespace StructId; -public enum TypeCheck +public enum ReferenceCheck { /// /// The check involves ensuring the type exists in the compilation. @@ -20,18 +20,18 @@ public enum TypeCheck ValueIsType, } -public abstract class TemplateGenerator(string valueType, string stringTemplate, string typeTemplate, TypeCheck interfaceCheck = TypeCheck.ValueIsType) : IIncrementalGenerator +public abstract class TemplateGenerator(string referenceType, string stringTemplate, string typeTemplate, ReferenceCheck referenceCheck = ReferenceCheck.ValueIsType) : IIncrementalGenerator { - record struct TemplateArgs(string TargetNamespace, INamedTypeSymbol StructId, INamedTypeSymbol ValueType, INamedTypeSymbol InterfaceType, INamedTypeSymbol StringType); + record struct TemplateArgs(string TargetNamespace, INamedTypeSymbol StructId, INamedTypeSymbol ValueType, INamedTypeSymbol ReferenceType, INamedTypeSymbol StringType); - public void Initialize(IncrementalGeneratorInitializationContext context) + public virtual void Initialize(IncrementalGeneratorInitializationContext context) { var targetNamespace = context.AnalyzerConfigOptionsProvider .Select((x, _) => x.GlobalOptions.TryGetValue("build_property.StructIdNamespace", out var ns) ? ns : "StructId"); // Locate the required types var types = context.CompilationProvider - .Select((x, _) => (InterfaceType: x.GetTypeByMetadataName(valueType), StringType: x.GetTypeByMetadataName("System.String"))); + .Select((x, _) => (ReferenceType: x.GetTypeByMetadataName(referenceType), StringType: x.GetTypeByMetadataName("System.String"))); var ids = context.CompilationProvider .SelectMany((x, _) => x.Assembly.GetAllTypes().OfType()) @@ -40,11 +40,11 @@ public void Initialize(IncrementalGeneratorInitializationContext context) var combined = ids.Combine(types) // NOTE: we never generate for compilations that don't have the specified value interface type - .Where(x => x.Right.InterfaceType != null || x.Right.StringType == null) + .Where(x => x.Right.ReferenceType != null || x.Right.StringType == null) .Combine(targetNamespace) .Select((x, _) => { - var ((structId, (interfaceType, stringType)), targetNamespace) = x; + var ((structId, (referenceType, stringType)), targetNamespace) = x; // The value type is either a generic type argument for IStructId, or the string type // for the non-generic IStructId @@ -53,15 +53,14 @@ public void Initialize(IncrementalGeneratorInitializationContext context) .TypeArguments.OfType().FirstOrDefault() ?? stringType!; - return new TemplateArgs(targetNamespace, structId, valueType, interfaceType!, stringType!); + return new TemplateArgs(targetNamespace, structId, valueType, referenceType!, stringType!); }); - if (interfaceCheck == TypeCheck.ValueIsType) - combined = combined.Where(x => x.ValueType.Is(x.InterfaceType)); + if (referenceCheck == ReferenceCheck.ValueIsType) + combined = combined.Where(x => x.ValueType.Is(x.ReferenceType)); context.RegisterImplementationSourceOutput(combined, GenerateCode); } - void GenerateCode(SourceProductionContext context, TemplateArgs args) { var ns = args.StructId.ContainingNamespace.Equals(args.StructId.ContainingModule.GlobalNamespace, SymbolEqualityComparer.Default) diff --git a/src/StructId.FunctionalTests/Functional.cs b/src/StructId.FunctionalTests/Functional.cs index ebf52ad..7abd13c 100644 --- a/src/StructId.FunctionalTests/Functional.cs +++ b/src/StructId.FunctionalTests/Functional.cs @@ -1,4 +1,5 @@ using Newtonsoft.Json; +using Newtonsoft.Json.Linq; namespace StructId.Functional; @@ -30,10 +31,19 @@ public void Newtonsoft() var user = new User(new UserId(1), "User", new Wallet(new WalletId("1234"), "Wallet")); var json = JsonConvert.SerializeObject(product, Formatting.Indented); + + // Serialized as a primitive + Assert.Equal(JTokenType.String, JObject.Parse(json).Property("Id")!.Value.Type); + var product2 = JsonConvert.DeserializeObject(json); Assert.Equal(product, product2); json = JsonConvert.SerializeObject(user, Formatting.Indented); + + // Serialized as a primitive + Assert.Equal(JTokenType.Integer, JObject.Parse(json).Property("Id")!.Value.Type); + Assert.Equal(JTokenType.String, JObject.Parse(json).SelectToken("$.Wallet.Id")!.Type); + var user2 = JsonConvert.DeserializeObject(json); Assert.Equal(user, user2); } diff --git a/src/StructId.Tests/ConstructorGeneratorTests.cs b/src/StructId.Tests/ConstructorGeneratorTests.cs index b35cab9..51abb19 100644 --- a/src/StructId.Tests/ConstructorGeneratorTests.cs +++ b/src/StructId.Tests/ConstructorGeneratorTests.cs @@ -10,6 +10,7 @@ public async Task GenerateRecordConstructor() { var test = new CSharpSourceGeneratorTest { + ReferenceAssemblies = ReferenceAssemblies.Net.Net80, TestBehaviors = TestBehaviors.SkipGeneratedSourcesCheck, TestState = { @@ -33,6 +34,7 @@ public async Task GenerateRecordStringConstructor() { var test = new CSharpSourceGeneratorTest { + ReferenceAssemblies = ReferenceAssemblies.Net.Net80, TestBehaviors = TestBehaviors.SkipGeneratedSourcesCheck, TestState = { @@ -56,6 +58,7 @@ public async Task GenerateRecordConstructorInGlobalNamespace() { var test = new CSharpSourceGeneratorTest { + ReferenceAssemblies = ReferenceAssemblies.Net.Net80, TestBehaviors = TestBehaviors.SkipGeneratedSourcesCheck, TestState = { diff --git a/src/StructId.Tests/NewtonsoftJsonGeneratorTests.cs b/src/StructId.Tests/NewtonsoftJsonGeneratorTests.cs new file mode 100644 index 0000000..cb0de63 --- /dev/null +++ b/src/StructId.Tests/NewtonsoftJsonGeneratorTests.cs @@ -0,0 +1,115 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Testing; +using Microsoft.CodeAnalysis.Testing; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Xunit.Sdk; + +namespace StructId; + +public class NewtonsoftJsonGeneratorTests +{ + [Fact] + public async Task DoesNotGenerateIfNewtonsoftJsonNotPresent() + { + var test = new CSharpSourceGeneratorTest + { + ReferenceAssemblies = ReferenceAssemblies.Net.Net80, + TestState = + { + Sources = + { + """ + using StructId; + + public readonly partial record struct UserId(int Value) : IStructId; + """, + }, + }, + }.WithAnalyzerStructId(); + + await test.RunAsync(); + } + + [Fact] + public async Task GenerateIfNewtonsoftJsonPresent() + { + var test = new StructIdSourceGeneratorTest("int") + { + SolutionTransforms = + { + (solution, projectId) => solution + .GetProject(projectId)? + .AddMetadataReference(MetadataReference.CreateFromFile(typeof(JsonConvert).Assembly.Location)) + .Solution ?? solution + }, + ReferenceAssemblies = ReferenceAssemblies.Net.Net80, + TestState = + { + Sources = + { + """ + using StructId; + + public readonly partial record struct UserId(int Value) : IStructId; + """, + }, + GeneratedSources = + { + (typeof(NewtonsoftJsonGenerator), "UserId.cs", + ThisAssembly.Resources.StructId.Templates.NewtonsoftJsonConverterT.Text.Replace("TStruct", "UserId").Replace("TValue", "int"), + Encoding.UTF8), + (typeof(NewtonsoftJsonGenerator), "NewtonsoftJsonConverter.cs", + ThisAssembly.Resources.StructId.Templates.NewtonsoftJsonConverter_1.Text, + Encoding.UTF8) + }, + }, + }.WithAnalyzerStructId(); + + await test.RunAsync(); + } + + class StructIdSourceGeneratorTest : CSharpSourceGeneratorTest + where TGenerator : new() + { + public StructIdSourceGeneratorTest(string? tvalue = null) + { + ReferenceAssemblies = ReferenceAssemblies.Net.Net80; + + if (tvalue != null) + { + TestState.GeneratedSources.Add( + (typeof(NewableGenerator), "UserId.cs", + ThisAssembly.Resources.StructId.Templates.NewableT.Text.Replace("TStruct", "UserId").Replace("TValue", tvalue), + Encoding.UTF8)); + TestState.GeneratedSources.Add( + (typeof(ParsableGenerator), "UserId.cs", + ThisAssembly.Resources.StructId.Templates.ParsableT.Text.Replace("TStruct", "UserId").Replace("TValue", "int"), + Encoding.UTF8)); + } + else + { + TestState.GeneratedSources.Add( + (typeof(NewableGenerator), "UserId.cs", + ThisAssembly.Resources.StructId.Templates.Newable.Text.Replace("SStruct", "UserId"), + Encoding.UTF8)); + TestState.GeneratedSources.Add( + (typeof(ParsableGenerator), "UserId.cs", + ThisAssembly.Resources.StructId.Templates.Parsable.Text.Replace("SStruct", "UserId"), + Encoding.UTF8)); + } + } + + protected override IEnumerable GetSourceGenerators() + { + yield return typeof(NewableGenerator); + yield return typeof(ParsableGenerator); + yield return typeof(TGenerator); + } + } +} diff --git a/src/StructId.Tests/RecordAnalyzerTests.cs b/src/StructId.Tests/RecordAnalyzerTests.cs index 3f54228..5293805 100644 --- a/src/StructId.Tests/RecordAnalyzerTests.cs +++ b/src/StructId.Tests/RecordAnalyzerTests.cs @@ -15,6 +15,7 @@ public async Task ReadonlyRecordStructNoStructId() { var test = new Test { + ReferenceAssemblies = ReferenceAssemblies.Net.Net80, TestCode = """ public readonly record struct UserId(int Value); @@ -29,6 +30,7 @@ public async Task RecordStructNoStructId() { var test = new Test { + ReferenceAssemblies = ReferenceAssemblies.Net.Net80, TestCode = """ public record struct UserId(int Value); @@ -43,6 +45,7 @@ public async Task ReadonlyStringRecordStructNotPartial() { var test = new Test { + ReferenceAssemblies = ReferenceAssemblies.Net.Net80, TestCode = """ using StructId; @@ -62,6 +65,7 @@ public async Task ReadonlyRecordStructNotPartial() { var test = new Test { + ReferenceAssemblies = ReferenceAssemblies.Net.Net80, TestCode = """ using StructId; @@ -81,6 +85,7 @@ public async Task PartialRecordStructNotReadonly() { var test = new Test { + ReferenceAssemblies = ReferenceAssemblies.Net.Net80, TestCode = """ using StructId; @@ -102,6 +107,7 @@ public async Task PartialStringRecordStructNotReadonly() { var test = new Test { + ReferenceAssemblies = ReferenceAssemblies.Net.Net80, TestCode = """ using StructId; @@ -124,6 +130,7 @@ public async Task RecordStructNotReadonlyNorPartial() { var test = new Test { + ReferenceAssemblies = ReferenceAssemblies.Net.Net80, TestCode = """ using StructId; @@ -143,6 +150,7 @@ public async Task StructNotStructId() { var test = new Test { + ReferenceAssemblies = ReferenceAssemblies.Net.Net80, TestCode = """ using StructId; @@ -162,6 +170,7 @@ public async Task ClassNotStructId() { var test = new Test { + ReferenceAssemblies = ReferenceAssemblies.Net.Net80, TestCode = """ using StructId; diff --git a/src/StructId.Tests/RecordCodeFixTests.cs b/src/StructId.Tests/RecordCodeFixTests.cs index 00db668..ecc842c 100644 --- a/src/StructId.Tests/RecordCodeFixTests.cs +++ b/src/StructId.Tests/RecordCodeFixTests.cs @@ -17,6 +17,7 @@ public async Task AddPartialKeyword() { var test = new CSharpCodeFixTest { + ReferenceAssemblies = ReferenceAssemblies.Net.Net80, TestCode = """ using StructId; @@ -46,6 +47,7 @@ public async Task AddReadOnlyModifier() { var test = new CSharpCodeFixTest { + ReferenceAssemblies = ReferenceAssemblies.Net.Net80, TestCode = """ using StructId; @@ -75,6 +77,7 @@ public async Task ChangeRecordStruct() { var test = new CSharpCodeFixTest { + ReferenceAssemblies = ReferenceAssemblies.Net.Net80, TestCode = """ using StructId; diff --git a/src/StructId.Tests/TestExtensions.cs b/src/StructId.Tests/TestExtensions.cs index 978d73d..85c2914 100644 --- a/src/StructId.Tests/TestExtensions.cs +++ b/src/StructId.Tests/TestExtensions.cs @@ -16,7 +16,10 @@ public static TTest WithCodeFixStructId(this TTest test) where TTest : Co test.WithAnalyzerStructId(); test.FixedState.Sources.Add(("IStructId.cs", ThisAssembly.Resources.StructId.IStructId.Text)); - test.FixedState.Sources.Add(("IStructId`1.cs", ThisAssembly.Resources.StructId.IStructIdT.Text)); + test.FixedState.Sources.Add(("IStructIdT.cs", ThisAssembly.Resources.StructId.IStructIdT.Text)); + test.FixedState.Sources.Add(("INewable.cs", ThisAssembly.Resources.StructId.INewable.Text)); + test.FixedState.Sources.Add(("INewableT.cs", ThisAssembly.Resources.StructId.INewableT.Text)); + // Fixes error CS0518: Predefined type 'System.Runtime.CompilerServices.IsExternalInit' is not defined or imported test.FixedState.Sources.Add( """ @@ -40,7 +43,10 @@ public static TTest WithAnalyzerStructId(this TTest test) where TTest : A }); test.TestState.Sources.Add(("IStructId.cs", ThisAssembly.Resources.StructId.IStructId.Text)); - test.TestState.Sources.Add(("IStructId`1.cs", ThisAssembly.Resources.StructId.IStructIdT.Text)); + test.TestState.Sources.Add(("IStructIdT.cs", ThisAssembly.Resources.StructId.IStructIdT.Text)); + test.TestState.Sources.Add(("INewable.cs", ThisAssembly.Resources.StructId.INewable.Text)); + test.TestState.Sources.Add(("INewableT.cs", ThisAssembly.Resources.StructId.INewableT.Text)); + // Fixes error CS0518: Predefined type 'System.Runtime.CompilerServices.IsExternalInit' is not defined or imported test.TestState.Sources.Add( """ diff --git a/src/StructId/Templates/NJsonConverter.cs b/src/StructId/Templates/NewtonsoftJsonConverter.cs similarity index 100% rename from src/StructId/Templates/NJsonConverter.cs rename to src/StructId/Templates/NewtonsoftJsonConverter.cs diff --git a/src/StructId/Templates/NJsonConverterT.cs b/src/StructId/Templates/NewtonsoftJsonConverterT.cs similarity index 100% rename from src/StructId/Templates/NJsonConverterT.cs rename to src/StructId/Templates/NewtonsoftJsonConverterT.cs diff --git a/src/StructId/StructIdConverters.NJsonConverter.cs b/src/StructId/Templates/NewtonsoftJsonConverter`1.cs similarity index 100% rename from src/StructId/StructIdConverters.NJsonConverter.cs rename to src/StructId/Templates/NewtonsoftJsonConverter`1.cs