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()
{