From 15d56a21cc0f9734ba525096cbfb506e237c6983 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20Dang=C3=A5rden?= Date: Tue, 27 Oct 2026 07:03:11 +0100 Subject: [PATCH 1/4] Docs: descriptionAttributes switch in the online generator On by default, like the config file setting. Off leaves schema descriptions as XML doc comments only, without [Description] attributes. --- docs/Linq2GraphQL.Docs/Components/GenerateClient.razor | 4 ++++ docs/Linq2GraphQL.Docs/Components/GenerateClient.razor.cs | 7 +++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/Linq2GraphQL.Docs/Components/GenerateClient.razor b/docs/Linq2GraphQL.Docs/Components/GenerateClient.razor index c6b8ed2..309cfdf 100644 --- a/docs/Linq2GraphQL.Docs/Components/GenerateClient.razor +++ b/docs/Linq2GraphQL.Docs/Components/GenerateClient.razor @@ -39,6 +39,10 @@ + + + + diff --git a/docs/Linq2GraphQL.Docs/Components/GenerateClient.razor.cs b/docs/Linq2GraphQL.Docs/Components/GenerateClient.razor.cs index 2eead68..a79b614 100644 --- a/docs/Linq2GraphQL.Docs/Components/GenerateClient.razor.cs +++ b/docs/Linq2GraphQL.Docs/Components/GenerateClient.razor.cs @@ -135,7 +135,8 @@ private async Task GenerateClientFromSchema() isLoading = true; var generator = new ClientGenerator(options.Namespace, options.ClientName, options.IncludeSubscriptions, options.EnumGeneratorStrategy, options.Nullable, options.IncludeDeprecated, - agent: options.Agent, standalone: options.Standalone); + descriptionAttributes: options.DescriptionAttributes, agent: options.Agent, + standalone: options.Standalone); var entries = IsIntrospectionJson(options.Schema) ? generator.Generate(options.Schema) : generator.GenerateFromSdl(options.Schema); @@ -160,7 +161,8 @@ private async Task GenerateClientAsync() isLoading = true; var generator = new ClientGenerator(options.Namespace, options.ClientName, options.IncludeSubscriptions, options.EnumGeneratorStrategy, options.Nullable, options.IncludeDeprecated, - agent: options.Agent, standalone: options.Standalone); + descriptionAttributes: options.DescriptionAttributes, agent: options.Agent, + standalone: options.Standalone); var entries = await generator.GenerateAsync(new(options.Url), options.Token); await SaveEntriesAsync(entries); } @@ -187,6 +189,7 @@ 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; } From b478f74460d73cec4dc9582b73e9109f8f6d5a68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20Dang=C3=A5rden?= Date: Tue, 27 Oct 2026 07:06:05 +0100 Subject: [PATCH 2/4] Docs: scalar mappings in the online generator Rows of scalar name and target type, the same targets the config file's scalarMappings accepts plus "CustomScalar class" for null. Validated with Helpers.ResolveScalarMappings, so errors match the command line; a scalar mapped twice is an error. Both generate paths share CreateGenerator(). --- .../Components/GenerateClient.razor | 23 ++++++++++ .../Components/GenerateClient.razor.cs | 44 +++++++++++++++---- 2 files changed, 59 insertions(+), 8 deletions(-) diff --git a/docs/Linq2GraphQL.Docs/Components/GenerateClient.razor b/docs/Linq2GraphQL.Docs/Components/GenerateClient.razor index 309cfdf..2100a33 100644 --- a/docs/Linq2GraphQL.Docs/Components/GenerateClient.razor +++ b/docs/Linq2GraphQL.Docs/Components/GenerateClient.razor @@ -51,6 +51,29 @@ + + @foreach (var mapping in options.ScalarMappings) + { +
+ + + + @foreach (var target in Linq2GraphQL.Generator.Helpers.SupportedTargetTypeNames) + { + + } + + +
+ } + +
+ Maps a GraphQL scalar to a C# type, overriding the built-in mapping. Choose + CustomScalar class to generate a class for a scalar that is mapped by default. +
+
+
diff --git a/docs/Linq2GraphQL.Docs/Components/GenerateClient.razor.cs b/docs/Linq2GraphQL.Docs/Components/GenerateClient.razor.cs index a79b614..ba7f23c 100644 --- a/docs/Linq2GraphQL.Docs/Components/GenerateClient.razor.cs +++ b/docs/Linq2GraphQL.Docs/Components/GenerateClient.razor.cs @@ -123,6 +123,32 @@ private async Task LoadSchemaFileAsync(InputFileChangeEventArgs e) /// private static bool IsIntrospectionJson(string schema) => schema?.TrimStart().StartsWith('{') == true; + private ClientGenerator CreateGenerator() + { + return new ClientGenerator(options.Namespace, options.ClientName, options.IncludeSubscriptions, + options.EnumGeneratorStrategy, options.Nullable, options.IncludeDeprecated, + ResolveScalarMappings(), options.DescriptionAttributes, options.Agent, options.Standalone); + } + + /// + /// The mapping rows as the config file's scalarMappings, validated the same way the command line does. + /// An empty target means "generate a CustomScalar class", like null in the config file. + /// + private IReadOnlyDictionary ResolveScalarMappings() + { + var mappings = new Dictionary(StringComparer.InvariantCultureIgnoreCase); + + foreach (var mapping in options.ScalarMappings.Where(e => !string.IsNullOrWhiteSpace(e.Scalar))) + { + if (!mappings.TryAdd(mapping.Scalar.Trim(), mapping.Target)) + { + throw new InvalidOperationException($"The scalar '{mapping.Scalar.Trim()}' is mapped more than once."); + } + } + + return Helpers.ResolveScalarMappings(mappings); + } + private async Task GenerateClientFromSchema() { try @@ -133,10 +159,7 @@ private async Task GenerateClientFromSchema() } isLoading = true; - var generator = new ClientGenerator(options.Namespace, options.ClientName, options.IncludeSubscriptions, - options.EnumGeneratorStrategy, options.Nullable, options.IncludeDeprecated, - descriptionAttributes: options.DescriptionAttributes, agent: options.Agent, - standalone: options.Standalone); + var generator = CreateGenerator(); var entries = IsIntrospectionJson(options.Schema) ? generator.Generate(options.Schema) : generator.GenerateFromSdl(options.Schema); @@ -159,10 +182,7 @@ private async Task GenerateClientAsync() try { isLoading = true; - var generator = new ClientGenerator(options.Namespace, options.ClientName, options.IncludeSubscriptions, - options.EnumGeneratorStrategy, options.Nullable, options.IncludeDeprecated, - descriptionAttributes: options.DescriptionAttributes, agent: options.Agent, - standalone: options.Standalone); + var generator = CreateGenerator(); var entries = await generator.GenerateAsync(new(options.Url), options.Token); await SaveEntriesAsync(entries); } @@ -193,4 +213,12 @@ public class GenerateOptions public EnumGeneratorStrategy EnumGeneratorStrategy { get; set; } public string Schema { get; set; } + + public List ScalarMappings { get; set; } = new(); +} + +public class ScalarMapping +{ + public string Scalar { get; set; } + public string Target { get; set; } = "string"; } \ No newline at end of file From 0094d1631c3b8b25e4f033e4cd718a8667114424 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20Dang=C3=A5rden?= Date: Tue, 27 Oct 2026 07:21:53 +0100 Subject: [PATCH 3/4] Online generator: review scalar mappings from the loaded schema Instead of typing scalar names blind, the page reads the schema first and lists its scalars with their default C# types. A summary shows how many generate a CustomScalar class and how many were changed; a review modal edits them and copies the page's settings as a linq2graphql.json file. Generator: - ClientGenerator.ReadSchemaAsync/ReadSchema/ReadSchemaFromSdl read a schema without generating, and Generate(RootSchema) is public. - Schema.GetScalars() lists the scalars with their default mapping (ScalarInfo). - Field.CoreType no longer keeps the first run's result: it depends on the run's settings, so generating twice from one schema with different scalar mappings returned the first run's types. --- .../Components/GenerateClient.razor | 53 +++--- .../Components/GenerateClient.razor.cs | 167 +++++++++++++----- .../Components/ScalarMappingsModal.razor | 111 ++++++++++++ src/Linq2GraphQL.Generator/ClientGenerator.cs | 41 ++++- .../GraphQLSchema/RootSchema.cs | 10 +- .../GraphQLSchema/ScalarInfo.cs | 22 +++ .../GraphQLSchema/Schema.cs | 20 +++ test/Linq2GraphQL.Tests/ScalarMappingTests.cs | 47 +++++ 8 files changed, 385 insertions(+), 86 deletions(-) create mode 100644 docs/Linq2GraphQL.Docs/Components/ScalarMappingsModal.razor create mode 100644 src/Linq2GraphQL.Generator/GraphQLSchema/ScalarInfo.cs diff --git a/docs/Linq2GraphQL.Docs/Components/GenerateClient.razor b/docs/Linq2GraphQL.Docs/Components/GenerateClient.razor index 2100a33..8df96ec 100644 --- a/docs/Linq2GraphQL.Docs/Components/GenerateClient.razor +++ b/docs/Linq2GraphQL.Docs/Components/GenerateClient.razor @@ -51,29 +51,6 @@ - - @foreach (var mapping in options.ScalarMappings) - { -
- - - - @foreach (var target in Linq2GraphQL.Generator.Helpers.SupportedTargetTypeNames) - { - - } - - -
- } - -
- Maps a GraphQL scalar to a C# type, overriding the built-in mapping. Choose - CustomScalar class to generate a class for a scalar that is mapped by default. -
-
-
@@ -89,8 +66,10 @@ - +
+ + +
@@ -108,13 +87,31 @@ - +
+ + +
+
+ @if (IsLoaded) + { + + Scalars: @CustomScalars.Count() of @MappableScalars.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 ba7f23c..5c93555 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,73 +125,142 @@ private async Task LoadSchemaFileAsync(InputFileChangeEventArgs e) /// private static bool IsIntrospectionJson(string schema) => schema?.TrimStart().StartsWith('{') == true; - private ClientGenerator CreateGenerator() - { - return new ClientGenerator(options.Namespace, options.ClientName, options.IncludeSubscriptions, - options.EnumGeneratorStrategy, options.Nullable, options.IncludeDeprecated, - ResolveScalarMappings(), options.DescriptionAttributes, options.Agent, options.Standalone); - } - /// - /// The mapping rows as the config file's scalarMappings, validated the same way the command line does. - /// An empty target means "generate a CustomScalar class", like null in the config file. + /// 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 IReadOnlyDictionary ResolveScalarMappings() + 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 MappableScalars => scalars.Where(e => !e.IsBuiltIn); + + private IEnumerable CustomScalars => MappableScalars.Where(e => + (options.ScalarOverrides.TryGetValue(e.Name, out var target) ? target : e.DefaultTarget ?? "") == ""); + + private int OverrideCount => MappableScalars.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 () => { - var mappings = new Dictionary(StringComparer.InvariantCultureIgnoreCase); + await EnsureLoadedAsync(source); + await SaveEntriesAsync(CreateGenerator().Generate(loadedSchema)); + }); - foreach (var mapping in options.ScalarMappings.Where(e => !string.IsNullOrWhiteSpace(e.Scalar))) + private async Task EnsureLoadedAsync(SchemaSource source) + { + if (loadedSchema != null && loadedFrom == source) { - if (!mappings.TryAdd(mapping.Scalar.Trim(), mapping.Target)) - { - throw new InvalidOperationException($"The scalar '{mapping.Scalar.Trim()}' is mapped more than once."); - } + return; } - return Helpers.ResolveScalarMappings(mappings); - } + var reader = CreateGenerator(); + RootSchema schema; - private async Task GenerateClientFromSchema() - { - try + if (source.Url != null) { - if (string.IsNullOrWhiteSpace(options.Schema)) - { - throw new InvalidOperationException("Upload or paste a schema first."); - } - - isLoading = true; - var generator = CreateGenerator(); - var entries = IsIntrospectionJson(options.Schema) - ? generator.Generate(options.Schema) - : generator.GenerateFromSdl(options.Schema); - await SaveEntriesAsync(entries); + 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 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 MappableScalars.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 + }; - private async Task GenerateClientAsync() + 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 = CreateGenerator(); - 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 { @@ -214,11 +285,9 @@ public class GenerateOptions public string Schema { get; set; } - public List ScalarMappings { get; set; } = new(); + /// + /// 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); } - -public class ScalarMapping -{ - public string Scalar { get; set; } - public string Target { get; set; } = "string"; -} \ No newline at end of file diff --git a/docs/Linq2GraphQL.Docs/Components/ScalarMappingsModal.razor b/docs/Linq2GraphQL.Docs/Components/ScalarMappingsModal.razor new file mode 100644 index 0000000..8743403 --- /dev/null +++ b/docs/Linq2GraphQL.Docs/Components/ScalarMappingsModal.razor @@ -0,0 +1,111 @@ +@using Linq2GraphQL.Generator +@using TabBlazor.Services + +

+ How each scalar in the schema becomes a C# type. Change a row to override the built-in mapping, or choose + CustomScalar class to generate a class for the scalar instead. +

+ +
+ + + + + + @foreach (var scalar in Scalars.Where(e => !e.IsBuiltIn)) + { + + + + + + } + +
ScalarC# type
+ @scalar.Name + @if (!string.IsNullOrWhiteSpace(scalar.Description)) + { +
@scalar.Description
+ } +
+ + + @if (Overrides.ContainsKey(scalar.Name)) + { + changed + + reset + + } + else + { + default + } +
+
+ +@if (Scalars.Any(e => e.IsBuiltIn)) +{ +

+ GraphQL's own scalars always map the same way: + @string.Join(", ", Scalars.Where(e => e.IsBuiltIn).Select(e => $"{e.Name} → {e.DefaultTarget}")). +

+} + +
+ + + +
+ +@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/src/Linq2GraphQL.Generator/ClientGenerator.cs b/src/Linq2GraphQL.Generator/ClientGenerator.cs index 45cc8f8..6918cd5 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/RootSchema.cs b/src/Linq2GraphQL.Generator/GraphQLSchema/RootSchema.cs index 75f63f3..0b3d638 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 0000000..2de9e18 --- /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. They are always mapped and never get a CustomScalar + /// class, so an override that opts one of them out has nothing to generate. + /// + 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 5fd0358..06aa531 100644 --- a/src/Linq2GraphQL.Generator/GraphQLSchema/Schema.cs +++ b/src/Linq2GraphQL.Generator/GraphQLSchema/Schema.cs @@ -66,6 +66,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 = BuiltInTypes.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 9c94854..a7a344f 100644 --- a/test/Linq2GraphQL.Tests/ScalarMappingTests.cs +++ b/test/Linq2GraphQL.Tests/ScalarMappingTests.cs @@ -146,6 +146,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() { From 9a6851af38bb551dcc7ee5937dfc0ce00529ff17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20Dang=C3=A5rden?= Date: Tue, 27 Oct 2026 07:44:14 +0100 Subject: [PATCH 4/4] Scalar mappings: list every scalar; GraphQL built-ins can't be opted out - The online generator's review modal lists every scalar of the schema, GraphQL's own Int/Float/String/Boolean included (after the schema's own scalars), each with an editable C# type. Built-ins offer no CustomScalar option. - Generator: mapping a built-in scalar to null now fails with a clear message. No CustomScalar class is generated for them, so "Int": null used to produce a client referring to a type that does not exist. Mapping them to another type (Int -> long) still works. Helpers.BuiltInScalars replaces Schema's private list. - README and Generator docs say built-ins can be remapped but not opted out. --- README.md | 3 +- .../Components/GenerateClient.razor | 2 +- .../Components/GenerateClient.razor.cs | 8 ++---- .../Components/ScalarMappingsModal.razor | 24 ++++++++-------- docs/Linq2GraphQL.Docs/Pages/Generator.razor | 2 ++ .../GraphQLSchema/Helpers.cs | 15 ++++++++++ .../GraphQLSchema/ScalarInfo.cs | 4 +-- .../GraphQLSchema/Schema.cs | 10 ++----- test/Linq2GraphQL.Tests/ScalarMappingTests.cs | 28 +++++++++++++++++++ 9 files changed, 67 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 2366282..29054e5 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 8df96ec..d8bb253 100644 --- a/docs/Linq2GraphQL.Docs/Components/GenerateClient.razor +++ b/docs/Linq2GraphQL.Docs/Components/GenerateClient.razor @@ -101,7 +101,7 @@ @if (IsLoaded) { - Scalars: @CustomScalars.Count() of @MappableScalars.Count() generate a CustomScalar class, + @scalars.Count scalars: @CustomScalars.Count() generate a CustomScalar class, @OverrideCount changed from the default. diff --git a/docs/Linq2GraphQL.Docs/Components/GenerateClient.razor.cs b/docs/Linq2GraphQL.Docs/Components/GenerateClient.razor.cs index 5c93555..0faa261 100644 --- a/docs/Linq2GraphQL.Docs/Components/GenerateClient.razor.cs +++ b/docs/Linq2GraphQL.Docs/Components/GenerateClient.razor.cs @@ -140,12 +140,10 @@ private record SchemaSource(string Url, string Token, string Schema, bool Includ private bool IsLoaded => loadedSchema != null && (loadedFrom == UrlSource || loadedFrom == TextSource); - private IEnumerable MappableScalars => scalars.Where(e => !e.IsBuiltIn); - - private IEnumerable CustomScalars => MappableScalars.Where(e => + private IEnumerable CustomScalars => scalars.Where(e => (options.ScalarOverrides.TryGetValue(e.Name, out var target) ? target : e.DefaultTarget ?? "") == ""); - private int OverrideCount => MappableScalars.Count(e => options.ScalarOverrides.ContainsKey(e.Name)); + private int OverrideCount => scalars.Count(e => options.ScalarOverrides.ContainsKey(e.Name)); private Task LoadFromUrlAsync() => RunAsync(() => EnsureLoadedAsync(UrlSource)); @@ -214,7 +212,7 @@ private ClientGenerator CreateGenerator() /// private Dictionary ScalarMappings() { - return MappableScalars.Where(e => options.ScalarOverrides.ContainsKey(e.Name)) + return scalars.Where(e => options.ScalarOverrides.ContainsKey(e.Name)) .ToDictionary(e => e.Name, e => options.ScalarOverrides[e.Name] is "" ? null : options.ScalarOverrides[e.Name]); } diff --git a/docs/Linq2GraphQL.Docs/Components/ScalarMappingsModal.razor b/docs/Linq2GraphQL.Docs/Components/ScalarMappingsModal.razor index 8743403..858579b 100644 --- a/docs/Linq2GraphQL.Docs/Components/ScalarMappingsModal.razor +++ b/docs/Linq2GraphQL.Docs/Components/ScalarMappingsModal.razor @@ -2,8 +2,9 @@ @using TabBlazor.Services

- How each scalar in the schema becomes a C# type. Change a row to override the built-in mapping, or choose - CustomScalar class to generate a class for the scalar instead. + 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.

@@ -12,11 +13,15 @@ ScalarC# type - @foreach (var scalar in Scalars.Where(e => !e.IsBuiltIn)) + @foreach (var scalar in Scalars.OrderBy(e => e.IsBuiltIn)) { @scalar.Name + @if (scalar.IsBuiltIn) + { + GraphQL built-in + } @if (!string.IsNullOrWhiteSpace(scalar.Description)) {
@scalar.Description
@@ -25,7 +30,10 @@