diff --git a/README.md b/README.md index 2366282f..29054e5f 100644 --- a/README.md +++ b/README.md @@ -174,7 +174,8 @@ recognise. `scalarMappings` lets you override both halves of that: * Change an existing mapping the same way. `"DateTime": "System.DateTime"` emits `DateTime` instead of the default `DateTimeOffset`. * Map a scalar to **null** to opt it out of the built-in mapping, so a `CustomScalar` class is - generated for it instead and you control the conversion yourself. + generated for it instead and you control the conversion yourself. GraphQL's own `Int`, `Float`, + `String` and `Boolean` can be mapped to another type but not opted out. Scalar names are matched case insensitively. The target must be one of the supported simple types - `bool`, `byte`, `sbyte`, `char`, `short`, `ushort`, `int`, `uint`, `long`, `ulong`, `float`, diff --git a/docs/Linq2GraphQL.Docs/Components/GenerateClient.razor b/docs/Linq2GraphQL.Docs/Components/GenerateClient.razor index c6b8ed29..d8bb2537 100644 --- a/docs/Linq2GraphQL.Docs/Components/GenerateClient.razor +++ b/docs/Linq2GraphQL.Docs/Components/GenerateClient.razor @@ -39,6 +39,10 @@ + + + + @@ -62,8 +66,10 @@ - +
+ + +
@@ -81,13 +87,31 @@ - +
+ + +
+
+ @if (IsLoaded) + { + + @scalars.Count scalars: @CustomScalars.Count() generate a CustomScalar class, + @OverrideCount changed from the default. + + + } + else + { + Load the schema to review how its scalars map to C# types before generating. + } +
+ diff --git a/docs/Linq2GraphQL.Docs/Components/GenerateClient.razor.cs b/docs/Linq2GraphQL.Docs/Components/GenerateClient.razor.cs index 2eead680..0faa261a 100644 --- a/docs/Linq2GraphQL.Docs/Components/GenerateClient.razor.cs +++ b/docs/Linq2GraphQL.Docs/Components/GenerateClient.razor.cs @@ -1,4 +1,6 @@ using System.IO.Compression; +using System.Text.Json; +using System.Text.Json.Serialization; using Linq2GraphQL.Generator; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Forms; @@ -123,51 +125,140 @@ private async Task LoadSchemaFileAsync(InputFileChangeEventArgs e) /// private static bool IsIntrospectionJson(string schema) => schema?.TrimStart().StartsWith('{') == true; - private async Task GenerateClientFromSchema() + /// + /// Where a schema was read from. A schema is reused only while all of these are unchanged; deprecated fields + /// are part of what is read, so toggling them needs a new read too. + /// + private record SchemaSource(string Url, string Token, string Schema, bool IncludeDeprecated); + + private RootSchema loadedSchema; + private SchemaSource loadedFrom; + private List scalars = new(); + + private SchemaSource UrlSource => new(options.Url, options.Token, null, options.IncludeDeprecated); + private SchemaSource TextSource => new(null, null, options.Schema, options.IncludeDeprecated); + + private bool IsLoaded => loadedSchema != null && (loadedFrom == UrlSource || loadedFrom == TextSource); + + private IEnumerable CustomScalars => scalars.Where(e => + (options.ScalarOverrides.TryGetValue(e.Name, out var target) ? target : e.DefaultTarget ?? "") == ""); + + private int OverrideCount => scalars.Count(e => options.ScalarOverrides.ContainsKey(e.Name)); + + private Task LoadFromUrlAsync() => RunAsync(() => EnsureLoadedAsync(UrlSource)); + + private Task LoadFromSchemaAsync() => RunAsync(() => EnsureLoadedAsync(TextSource)); + + private Task GenerateClientAsync() => GenerateAsync(UrlSource); + + private Task GenerateClientFromSchema() => GenerateAsync(TextSource); + + private Task GenerateAsync(SchemaSource source) => RunAsync(async () => { - try + await EnsureLoadedAsync(source); + await SaveEntriesAsync(CreateGenerator().Generate(loadedSchema)); + }); + + private async Task EnsureLoadedAsync(SchemaSource source) + { + if (loadedSchema != null && loadedFrom == source) { - if (string.IsNullOrWhiteSpace(options.Schema)) - { - throw new InvalidOperationException("Upload or paste a schema first."); - } + return; + } - isLoading = true; - var generator = new ClientGenerator(options.Namespace, options.ClientName, options.IncludeSubscriptions, - options.EnumGeneratorStrategy, options.Nullable, options.IncludeDeprecated, - agent: options.Agent, standalone: options.Standalone); - var entries = IsIntrospectionJson(options.Schema) - ? generator.Generate(options.Schema) - : generator.GenerateFromSdl(options.Schema); - await SaveEntriesAsync(entries); + var reader = CreateGenerator(); + RootSchema schema; + + if (source.Url != null) + { + schema = await reader.ReadSchemaAsync(new(source.Url), source.Token); } - catch (Exception ex) + else if (string.IsNullOrWhiteSpace(source.Schema)) { - var component = new RenderComponent().Set(e => e.Exception, ex); - var result = await modalService.ShowAsync("Error", component, new() { Size = ModalSize.Large }); + throw new InvalidOperationException("Upload or paste a schema first."); } - finally + else { - isLoading = false; + schema = IsIntrospectionJson(source.Schema) + ? reader.ReadSchema(source.Schema) + : reader.ReadSchemaFromSdl(source.Schema); } + + loadedSchema = schema; + loadedFrom = source; + scalars = schema.Data.Schema.GetScalars(); } + private async Task ReviewScalarsAsync() + { + var component = new RenderComponent() + .Set(e => e.Scalars, scalars) + .Set(e => e.Overrides, options.ScalarOverrides) + .Set(e => e.ConfigJson, ConfigJson); + await modalService.ShowAsync("Scalar mappings", component, new() { Size = ModalSize.Large }); + } - private async Task GenerateClientAsync() + private ClientGenerator CreateGenerator() + { + return new ClientGenerator(options.Namespace, options.ClientName, options.IncludeSubscriptions, + options.EnumGeneratorStrategy, options.Nullable, options.IncludeDeprecated, + Helpers.ResolveScalarMappings(ScalarMappings()), options.DescriptionAttributes, options.Agent, + options.Standalone); + } + + /// + /// The overrides for the scalars of the loaded schema, as the config file's scalarMappings: an empty target + /// becomes null, "generate a CustomScalar class". Overrides kept from another schema are left out. + /// + private Dictionary ScalarMappings() + { + return scalars.Where(e => options.ScalarOverrides.ContainsKey(e.Name)) + .ToDictionary(e => e.Name, e => options.ScalarOverrides[e.Name] is "" ? null : options.ScalarOverrides[e.Name]); + } + + /// + /// The page's settings as a linq2graphql.json file for the command line, leaving out settings that have their + /// default value. The token is never included. + /// + private string ConfigJson() + { + var mappings = ScalarMappings(); + var config = new GeneratorConfig + { + Endpoint = loadedFrom?.Url, + Namespace = options.Namespace, + Client = options.ClientName, + Subscriptions = options.IncludeSubscriptions ? true : null, + EnumStrategy = options.EnumGeneratorStrategy == EnumGeneratorStrategy.FailIfMissing + ? null + : options.EnumGeneratorStrategy.ToString(), + Nullable = options.Nullable ? true : null, + Deprecated = options.IncludeDeprecated ? true : null, + Agent = options.Agent ? true : null, + Standalone = options.Standalone ? true : null, + DescriptionAttributes = options.DescriptionAttributes ? null : false, + ScalarMappings = mappings.Count > 0 ? mappings : null + }; + + return JsonSerializer.Serialize(config, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = true + }); + } + + private async Task RunAsync(Func action) { try { isLoading = true; - var generator = new ClientGenerator(options.Namespace, options.ClientName, options.IncludeSubscriptions, - options.EnumGeneratorStrategy, options.Nullable, options.IncludeDeprecated, - agent: options.Agent, standalone: options.Standalone); - var entries = await generator.GenerateAsync(new(options.Url), options.Token); - await SaveEntriesAsync(entries); + await action(); } catch (Exception ex) { var component = new RenderComponent().Set(e => e.Exception, ex); - var result = await modalService.ShowAsync("Error", component, new() { Size = ModalSize.Large }); + await modalService.ShowAsync("Error", component, new() { Size = ModalSize.Large }); } finally { @@ -187,7 +278,14 @@ public class GenerateOptions public bool IncludeDeprecated { get; set; } public bool Standalone { get; set; } public bool Agent { get; set; } + public bool DescriptionAttributes { get; set; } = true; public EnumGeneratorStrategy EnumGeneratorStrategy { get; set; } public string Schema { get; set; } -} \ No newline at end of file + + /// + /// Scalar name to C# type for the scalars whose mapping was changed in the review; an empty type means + /// "generate a CustomScalar class". Kept per example, so a reloaded schema keeps its changes. + /// + public Dictionary ScalarOverrides { get; set; } = new(StringComparer.InvariantCultureIgnoreCase); +} diff --git a/docs/Linq2GraphQL.Docs/Components/ScalarMappingsModal.razor b/docs/Linq2GraphQL.Docs/Components/ScalarMappingsModal.razor new file mode 100644 index 00000000..858579bc --- /dev/null +++ b/docs/Linq2GraphQL.Docs/Components/ScalarMappingsModal.razor @@ -0,0 +1,111 @@ +@using Linq2GraphQL.Generator +@using TabBlazor.Services + +

+ Every scalar in the schema and the C# type it becomes. Change a row to override the default, or choose + CustomScalar class to generate a class for the scalar instead. GraphQL's own scalars can be mapped + to another type but not to a class. +

+ +
+ + + + + + @foreach (var scalar in Scalars.OrderBy(e => e.IsBuiltIn)) + { + + + + + + } + +
ScalarC# type
+ @scalar.Name + @if (scalar.IsBuiltIn) + { + GraphQL built-in + } + @if (!string.IsNullOrWhiteSpace(scalar.Description)) + { +
@scalar.Description
+ } +
+ + + @if (Overrides.ContainsKey(scalar.Name)) + { + changed + + reset + + } + else + { + default + } +
+
+ +
+ + + +
+ +@code { + [Parameter] public List Scalars { get; set; } + + /// + /// Scalar name to C# type, edited in place. An empty type means "generate a CustomScalar class", like null + /// in the config file. Only scalars that differ from their default are in here. + /// + [Parameter] public Dictionary Overrides { get; set; } + + /// + /// The page's settings, these overrides included, as the contents of a linq2graphql.json file. + /// + [Parameter] public Func ConfigJson { get; set; } + + [Inject] private IModalService modalService { get; set; } + [Inject] private TablerService tablerService { get; set; } + [Inject] private ToastService toastService { get; set; } + + private string Target(ScalarInfo scalar) + { + return Overrides.TryGetValue(scalar.Name, out var target) ? target : scalar.DefaultTarget ?? ""; + } + + private void SetTarget(ScalarInfo scalar, string target) + { + if (target == (scalar.DefaultTarget ?? "")) + { + Overrides.Remove(scalar.Name); + } + else + { + Overrides[scalar.Name] = target; + } + } + + private async Task CopyConfigAsync() + { + await tablerService.CopyToClipboard(ConfigJson()); + await toastService.AddToastAsync(new() + { + Title = "Copy Complete", Message = "The settings have been copied to the clipboard as linq2graphql.json" + }); + } +} diff --git a/docs/Linq2GraphQL.Docs/Pages/Generator.razor b/docs/Linq2GraphQL.Docs/Pages/Generator.razor index 78a7e811..7ccf28cc 100644 --- a/docs/Linq2GraphQL.Docs/Pages/Generator.razor +++ b/docs/Linq2GraphQL.Docs/Pages/Generator.razor @@ -141,6 +141,8 @@
  • Map a scalar to null to opt it out of the built-in mapping, so a CustomScalar class is generated for it and you control the conversion yourself. + GraphQL's own Int, Float, String and Boolean can be + mapped to another type but not opted out.
  • diff --git a/src/Linq2GraphQL.Generator/ClientGenerator.cs b/src/Linq2GraphQL.Generator/ClientGenerator.cs index 45cc8f85..6918cd5f 100644 --- a/src/Linq2GraphQL.Generator/ClientGenerator.cs +++ b/src/Linq2GraphQL.Generator/ClientGenerator.cs @@ -44,6 +44,15 @@ private void AddFile(string directory, string fileName, string content) public async Task> GenerateAsync(Uri uri, string authToken = null) { Console.WriteLine($"Start generating client for endpoint {uri.AbsoluteUri}"); + return Generate(await ReadSchemaAsync(uri, authToken)); + } + + ///

    + /// Reads the schema of a live endpoint with an introspection query, without generating anything, so it + /// can be inspected (see ) and then passed to . + /// + public async Task ReadSchemaAsync(Uri uri, string authToken = null) + { using var httpClient = new HttpClient(); if (!string.IsNullOrWhiteSpace(authToken)) @@ -71,29 +80,45 @@ public async Task> GenerateAsync(Uri uri, string authToken = nul } Console.WriteLine("Reading and deserializing schema information ..."); - var schemaJson = await response.Content.ReadAsStringAsync(); - - return Generate(schemaJson); + return ReadSchema(await response.Content.ReadAsStringAsync()); } public List Generate(string schemaJson) { - var rootSchema = JsonSerializer.Deserialize(schemaJson, - new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); + return Generate(ReadSchema(schemaJson)); + } - return Generate(rootSchema); + /// + /// Reads an introspection result without generating anything. + /// + public RootSchema ReadSchema(string schemaJson) + { + return JsonSerializer.Deserialize(schemaJson, + new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); } /// /// Generates the client from a schema written in SDL instead of an introspection result. /// public List GenerateFromSdl(string sdl) + { + return Generate(ReadSchemaFromSdl(sdl)); + } + + /// + /// Reads a schema written in SDL without generating anything. + /// + public RootSchema ReadSchemaFromSdl(string sdl) { Console.WriteLine("Reading schema from SDL ..."); - return Generate(SdlSchemaReader.Read(sdl, includeDeprecated)); + return SdlSchemaReader.Read(sdl, includeDeprecated); } - private List Generate(RootSchema rootSchema) + /// + /// Generates the client from a schema read with one of the ReadSchema methods. The same schema can + /// be generated from more than once, with different settings. + /// + public List Generate(RootSchema rootSchema) { entries.Clear(); diff --git a/src/Linq2GraphQL.Generator/GraphQLSchema/Helpers.cs b/src/Linq2GraphQL.Generator/GraphQLSchema/Helpers.cs index 6299086d..bba75bba 100644 --- a/src/Linq2GraphQL.Generator/GraphQLSchema/Helpers.cs +++ b/src/Linq2GraphQL.Generator/GraphQLSchema/Helpers.cs @@ -143,6 +143,13 @@ internal static string SafeVariableName(string name) "while",]; + /// + /// GraphQL's own scalars, apart from ID. They are always mapped to a simple type and never get a + /// CustomScalar class, so they cannot be opted out of the mapping - only mapped to another type. + /// + public static readonly IReadOnlySet BuiltInScalars = + new HashSet(StringComparer.Ordinal) { "Int", "Float", "String", "Boolean" }; + /// /// The built-in GraphQL scalar to CLR type mapping. Copied per generator run so that user /// supplied overrides never leak between runs - see . @@ -253,6 +260,14 @@ void Add(string emittedName, Type type) if (string.IsNullOrWhiteSpace(targetTypeName)) { + // Mapping names match case insensitively, so "int": null would remove Int from the table too. + if (BuiltInScalars.Contains(scalarName.Trim(), StringComparer.InvariantCultureIgnoreCase)) + { + throw new GeneratorConfigurationException( + $"The scalar '{scalarName.Trim()}' is built into GraphQL and cannot be opted out of the type " + + "mapping, because no CustomScalar class is generated for it. Map it to another type instead."); + } + result[scalarName.Trim()] = null; continue; } diff --git a/src/Linq2GraphQL.Generator/GraphQLSchema/RootSchema.cs b/src/Linq2GraphQL.Generator/GraphQLSchema/RootSchema.cs index 75f63f3c..0b3d6384 100644 --- a/src/Linq2GraphQL.Generator/GraphQLSchema/RootSchema.cs +++ b/src/Linq2GraphQL.Generator/GraphQLSchema/RootSchema.cs @@ -171,12 +171,20 @@ public class BaseField public BaseType Type { get; set; } private CoreType coreType; + private GeneratorSettings coreTypeSettings; public CoreType CoreType { get { - coreType ??= Type.GetCoreType(); + // GetCoreType depends on the run's settings (type mapping, nullable), so a schema generated from + // more than once must not keep the first run's result. + if (coreType == null || coreTypeSettings != GeneratorSettings.Current) + { + coreType = Type.GetCoreType(); + coreTypeSettings = GeneratorSettings.Current; + } + return coreType; } } diff --git a/src/Linq2GraphQL.Generator/GraphQLSchema/ScalarInfo.cs b/src/Linq2GraphQL.Generator/GraphQLSchema/ScalarInfo.cs new file mode 100644 index 00000000..8528f772 --- /dev/null +++ b/src/Linq2GraphQL.Generator/GraphQLSchema/ScalarInfo.cs @@ -0,0 +1,22 @@ +namespace Linq2GraphQL.Generator; + +/// +/// A scalar of a schema and the C# type the generator maps it to when no scalarMappings override applies. +/// +public class ScalarInfo +{ + public string Name { get; init; } + + public string Description { get; init; } + + /// + /// GraphQL's own Int, Float, String and Boolean (see ). They can be + /// mapped to another type but not opted out to a CustomScalar class. + /// + public bool IsBuiltIn { get; init; } + + /// + /// The C# type name emitted for the scalar, or null when a CustomScalar class is generated for it. + /// + public string DefaultTarget { get; init; } +} diff --git a/src/Linq2GraphQL.Generator/GraphQLSchema/Schema.cs b/src/Linq2GraphQL.Generator/GraphQLSchema/Schema.cs index 5fd03583..8955213e 100644 --- a/src/Linq2GraphQL.Generator/GraphQLSchema/Schema.cs +++ b/src/Linq2GraphQL.Generator/GraphQLSchema/Schema.cs @@ -4,12 +4,6 @@ namespace Linq2GraphQL.Generator; public class Schema { - private static readonly List BuiltInTypes = new() - { - // "ID", - "Int", "Float", "String", "Boolean" - }; - public List Types { get; set; } [JsonPropertyName("queryType")] public GraphqlSchemaType SchemaQueryType { get; set; } @@ -31,7 +25,7 @@ public List GetAllTypesExceptSystemTypes() return Types.Where(e => e.Name != SchemaQueryType?.Name && !e.Name.StartsWith("__") && - !BuiltInTypes.Contains(e.Name) && + !Helpers.BuiltInScalars.Contains(e.Name) && e.Name != SchemaMutationType?.Name && e.Name != SchemaSubscriptionType?.Name) .ToList(); @@ -66,6 +60,26 @@ public List GetEnums() return GetAllTypesExceptSystemTypes().Where(e => e.Kind == TypeKind.Enum).ToList(); } + /// + /// Every scalar in the schema with the C# type it maps to by default, before any scalarMappings + /// override, sorted by name. + /// + public List GetScalars() + { + if (Types == null) return new(); + + return Types.Where(e => e.Kind == TypeKind.Scalar && !e.Name.StartsWith("__")) + .OrderBy(e => e.Name, StringComparer.Ordinal) + .Select(e => new ScalarInfo + { + Name = e.Name, + Description = e.Description, + IsBuiltIn = Helpers.BuiltInScalars.Contains(e.Name), + DefaultTarget = Helpers.DefaultTypeMapping.TryGetValue(e.Name, out var mapping) ? mapping.Name : null + }) + .ToList(); + } + public List GetCustomScalars() { var mappers = Helpers.EffectiveTypeMapping; diff --git a/test/Linq2GraphQL.Tests/ScalarMappingTests.cs b/test/Linq2GraphQL.Tests/ScalarMappingTests.cs index 9c94854a..96683a75 100644 --- a/test/Linq2GraphQL.Tests/ScalarMappingTests.cs +++ b/test/Linq2GraphQL.Tests/ScalarMappingTests.cs @@ -134,6 +134,34 @@ public void UnsupportedTargetType_FailsWithAHelpfulMessage() ex.Message.ShouldContain("DateTimeOffset"); } + // No CustomScalar class is generated for GraphQL's own scalars, so opting one out would leave the client + // referring to a type that does not exist. + [Theory] + [InlineData("Int")] + [InlineData("float")] + [InlineData("String")] + [InlineData("Boolean")] + public void Override_ToNull_ForABuiltInScalar_Fails(string scalarName) + { + var ex = Should.Throw(() => + Helpers.ResolveScalarMappings(new Dictionary { [scalarName] = null! })); + + ex.Message.ShouldContain(scalarName); + ex.Message.ShouldContain("built into GraphQL"); + } + + [Fact] + public void Override_ABuiltInScalar_ToAnotherType_Works() + { + var generator = new ClientGenerator("TestNs", "TestClient", false, EnumGeneratorStrategy.FailIfMissing, + false, false, Helpers.ResolveScalarMappings(new Dictionary { ["Int"] = "long" })); + + var entries = generator.GenerateFromSdl("type Query { count: Int }"); + + entries.Single(e => e.FileName == "QueryMethods.cs").Content.ShouldContain("GraphQuery Count()"); + ScalarFiles(entries).ShouldBeEmpty(); + } + [Fact] public void Overrides_DoNotLeakBetweenRuns() { @@ -146,6 +174,53 @@ public void Overrides_DoNotLeakBetweenRuns() Helpers.DefaultTypeMapping.ContainsKey("BigInt").ShouldBeFalse(); } + [Fact] + public void GetScalars_ListsEveryScalarWithItsDefaultMapping() + { + var generator = new ClientGenerator("TestNs", "TestClient", false, EnumGeneratorStrategy.FailIfMissing, + false, false); + + var scalars = generator.ReadSchema(SchemaJson).Data.Schema.GetScalars(); + + scalars.Select(e => (e.Name, e.DefaultTarget, e.IsBuiltIn)).ShouldBe(new (string, string?, bool)[] + { + ("BigInt", null, false), + ("DateTime", "DateTimeOffset", false), + ("Json", null, false), + ("String", "string", true) + }); + } + + [Fact] + public void GetScalars_FromSdl_MatchesDefaultsCaseInsensitively() + { + var generator = new ClientGenerator("TestNs", "TestClient", false, EnumGeneratorStrategy.FailIfMissing, + false, false); + + var scalars = generator.ReadSchemaFromSdl("scalar UUID scalar Long type Query { id: UUID big: Long }") + .Data.Schema.GetScalars(); + + scalars.Single(e => e.Name == "UUID").DefaultTarget.ShouldBe("Guid"); + scalars.Single(e => e.Name == "Long").DefaultTarget.ShouldBe("long"); + } + + [Fact] + public void ReadSchema_CanBeGeneratedFromMoreThanOnce() + { + var reader = new ClientGenerator("TestNs", "TestClient", false, EnumGeneratorStrategy.FailIfMissing, + false, false); + var rootSchema = reader.ReadSchema(SchemaJson); + + var mapped = new ClientGenerator("TestNs", "TestClient", false, EnumGeneratorStrategy.FailIfMissing, + false, false, Helpers.ResolveScalarMappings(new Dictionary { ["BigInt"] = "long" })) + .Generate(rootSchema); + var unmapped = new ClientGenerator("TestNs", "TestClient", false, EnumGeneratorStrategy.FailIfMissing, + false, false).Generate(rootSchema); + + ThingType(mapped).ShouldContain("long? Id"); + unmapped.Select(e => e.Content).ShouldBe(Generate().Select(e => e.Content)); + } + [Fact] public void ConfigFile_RoundTripsEverySetting() {