Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ Key details that bite:

`GeneratorSettings.Current.Nullable` is ambient static state read from inside templates; the nullable and non-nullable clients differ mainly in nullable annotations and `#pragma warning disable CS8618`.

`GeneratorSettings.Current.TypeMapping` is the GraphQL-scalar-to-CLR-type table for the run: `Helpers.DefaultTypeMapping` with any `scalarMappings` from the `--config` settings file applied on top (`GeneratorConfig`). It is read in exactly two places - `BaseType.GetCoreType` for the emitted type name and `Schema.GetCustomScalars`, which treats absence from the table as "generate a `CustomScalar` class for this scalar" - so mapping a scalar to a simple type also suppresses its generated class, and mapping it to `null` does the reverse. Override targets are validated against `Helpers.SupportedTargetTypes`, an allow-list, because a null `CoreType.CSharpType` silently changes nullability and the input-factory template rather than failing.


## Tests (test/)

`Linq2GraphQL.Tests` is xUnit + Shouldly + Moq. It spins up the real GraphQL server in-process with `WebApplicationFactory<Program>` — `Linq2GraphQL.TestServer` is HotChocolate over the POCOs in `TestServer.Shared`, and `TestServerNullable` is the same schema for the nullable client. `SampleClientFixture` / `SampleClientNullableFixture` wire the generated client to that in-memory host (safe mode on, SSE subscriptions), and test classes take them via `IClassFixture<>`. So most tests are end-to-end: an assertion failure can come from the expression parser, the query text, or the server's own resolvers.
Expand Down
84 changes: 84 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ Usage:
-es --enum-strategy If AddUnknownOption all enums will have an additional Unknown option
-nu --nullabel Nullable client [default: false]
-d --deprecated Include Deprecated as Obsolete
-cf --config Json settings file, see Configuration file below

As an example:

Expand All @@ -78,6 +79,89 @@ As an example:
Would generate a client from url *https://spacex-production.up.railway.app/* with the name *SpaceXClient* in the
namespace *SpaceX* to folder *Generated*

## Configuration file

Every option above can also be set in a json file, which keeps a long command line out of your build
scripts and gives you somewhere to check the settings in:

Linq2GraphQL --config linq2graphql.json

If the file is called `linq2graphql.json` and sits in the current directory it is picked up on its
own, so a repository with one in it just needs:

Linq2GraphQL

The generator prints the path of the file it read. An explicit `--config` always wins over the
discovered one, and pointing `--config` at a file that does not exist is an error - only the default
file is allowed to be absent.

```json
{
"endpoint": "https://spacex-production.up.railway.app/",
"client": "SpaceXClient",
"namespace": "SpaceX",
"output": "Generated",
"nullable": false,
"subscriptions": false,
"deprecated": false,
"enumStrategy": "FailIfMissing",
"scalarMappings": {
"DateTime": "System.DateTime",
"BigInt": "long",
"Json": null
}
}
```

| Setting | Type | Default | Command line |
|------------------|--------|--------------------------|-----------------------|
| `endpoint` | string | *required* | `<endpoint>` argument |
| `output` | string | `Linq2GraphQL_Generated` | `-o`, `--output` |
| `namespace` | string | `YourNamespace` | `-n`, `--namespace` |
| `client` | string | `GraphQLClient` | `-c`, `--client` |
| `token` | string | none | `-t`, `--token` |
| `subscriptions` | bool | `false` | `-s`, `--subscriptions` |
| `enumStrategy` | string | `FailIfMissing` | `-es`, `--enum-strategy` |
| `nullable` | bool | `false` | `-nu`, `--nullable` |
| `deprecated` | bool | `false` | `-d`, `--deprecated` |
| `scalarMappings` | object | none | *no equivalent* |

`endpoint` is required only in the sense that it has to come from somewhere - the file or the
command line argument. `enumStrategy` takes `AddUnknownOption` to give every generated enum an extra
`Unknown` member; any other value means `FailIfMissing`. `token` is settable here for completeness,
but a bearer token is usually better passed as `-t` than checked into a file.

Setting names are matched case insensitively. A name that is not in the table above fails the run,
so a typo like `scalarMapping` is reported rather than silently ignored. `scalarMappings` has no
command line equivalent, so it is the one setting a discovered file supplies that you cannot
override back off from the command line.

Everything in the file is optional, and an option you pass explicitly on the command line always wins
over the file - so you can keep the shared settings in the file and override one of them for a single
run:

Linq2GraphQL --config linq2graphql.json -o="SomewhereElse"

### Mapping scalars to your own types

By default the generator maps the well known GraphQL scalars to CLR types (`Int` to `int`, `DateTime`
to `DateTimeOffset`, and so on) and generates a `CustomScalar` class for every scalar it does not
recognise. `scalarMappings` lets you override both halves of that:

* Map a scalar to a **simple type** and it is emitted as that type - no `CustomScalar` class is
generated for it. `"BigInt": "long"` gives you `public long? Id { get; set; }`.
* 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.

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`,
`double`, `decimal`, `string`, `Guid`, `Uri`, `DateTime`, `DateTimeOffset`, `DateOnly`, `TimeOnly`,
`TimeSpan` - written either as the C# keyword (`long`), the type name (`Int64`) or its full name
(`System.Int64`). Anything else fails the run with an error rather than generating a client that does
not compile.

## Add Nuget

Latest
Expand Down
112 changes: 112 additions & 0 deletions docs/Linq2GraphQL.Docs/Pages/Index.razor
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,124 @@ Options:
-nu --nullabel Nullable client [default: false]
-es --enum-strategy If AddUnknownOption all enums will have an additional Unknown option
-s, --subscriptions Include subscriptions (Exprimental)
-d, --deprecated Include Deprecated as Obsolete
-cf --config Json settings file, defaults to linq2graphql.json in the current directory
</code></pre>
<p>As an example:</p>
<pre><code>Linq2GraphQL https://spacex-production.up.railway.app/ -c=&quot;SpaceXClient&quot; -n=&quot;SpaceX&quot; -o=&quot;Generated&quot;
</code></pre>
<p>Would generate a client from url <em><a href="https://spacex-production.up.railway.app/">https://spacex-production.up.railway.app/</a></em>
with the name <em>SpaceXClient</em> in the namespace <em>SpaceX</em> to folder <em>Generated</em></p>
<h2 id="configuration-file">Configuration file</h2>
<p>
Every option above can also be set in a json file, which keeps a long command line out of your
build scripts and gives you somewhere to check the settings in:
</p>
<pre><code>Linq2GraphQL --config linq2graphql.json
</code></pre>
<p>
If the file is called <em>linq2graphql.json</em> and sits in the current directory it is picked
up on its own, so a repository with one in it just needs:
</p>
<pre><code>Linq2GraphQL
</code></pre>
<p>
The generator prints the path of the file it read. An explicit <em>--config</em> always wins over
the discovered one, and pointing <em>--config</em> at a file that does not exist is an error -
only the default file is allowed to be absent.
</p>
<pre><code>{
&quot;endpoint&quot;: &quot;https://spacex-production.up.railway.app/&quot;,
&quot;client&quot;: &quot;SpaceXClient&quot;,
&quot;namespace&quot;: &quot;SpaceX&quot;,
&quot;output&quot;: &quot;Generated&quot;,
&quot;nullable&quot;: false,
&quot;subscriptions&quot;: false,
&quot;deprecated&quot;: false,
&quot;enumStrategy&quot;: &quot;FailIfMissing&quot;,
&quot;scalarMappings&quot;: {
&quot;DateTime&quot;: &quot;System.DateTime&quot;,
&quot;BigInt&quot;: &quot;long&quot;,
&quot;Json&quot;: null
}
}
</code></pre>
<table class="table">
<thead>
<tr>
<th>Setting</th>
<th>Type</th>
<th>Default</th>
<th>Command line</th>
</tr>
</thead>
<tbody>
<tr><td><em>endpoint</em></td><td>string</td><td>required</td><td>&lt;endpoint&gt; argument</td></tr>
<tr><td><em>output</em></td><td>string</td><td>Linq2GraphQL_Generated</td><td>-o, --output</td></tr>
<tr><td><em>namespace</em></td><td>string</td><td>YourNamespace</td><td>-n, --namespace</td></tr>
<tr><td><em>client</em></td><td>string</td><td>GraphQLClient</td><td>-c, --client</td></tr>
<tr><td><em>token</em></td><td>string</td><td>none</td><td>-t, --token</td></tr>
<tr><td><em>subscriptions</em></td><td>bool</td><td>false</td><td>-s, --subscriptions</td></tr>
<tr><td><em>enumStrategy</em></td><td>string</td><td>FailIfMissing</td><td>-es, --enum-strategy</td></tr>
<tr><td><em>nullable</em></td><td>bool</td><td>false</td><td>-nu, --nullable</td></tr>
<tr><td><em>deprecated</em></td><td>bool</td><td>false</td><td>-d, --deprecated</td></tr>
<tr><td><em>scalarMappings</em></td><td>object</td><td>none</td><td>no equivalent</td></tr>
</tbody>
</table>
<p>
<em>endpoint</em> is required only in the sense that it has to come from somewhere - the file or
the command line argument. <em>enumStrategy</em> takes <em>AddUnknownOption</em> to give every
generated enum an extra <em>Unknown</em> member; any other value means <em>FailIfMissing</em>.
<em>token</em> is settable here for completeness, but a bearer token is usually better passed as
<em>-t</em> than checked into a file.
</p>
<p>
Setting names are matched case insensitively. A name that is not in the table above fails the
run, so a typo like <em>scalarMapping</em> is reported rather than silently ignored.
<em>scalarMappings</em> has no command line equivalent, so it is the one setting a discovered
file supplies that you cannot override back off from the command line.
</p>
<p>
Everything in the file is optional, and an option you pass explicitly on the command line always
wins over the file - so you can keep the shared settings in the file and override one of them for
a single run:
</p>
<pre><code>Linq2GraphQL --config linq2graphql.json -o=&quot;SomewhereElse&quot;
</code></pre>
<h3 id="mapping-scalars">Mapping scalars to your own types</h3>
<p>
By default the generator maps the well known GraphQL scalars to CLR types (<em>Int</em> to
<em>int</em>, <em>DateTime</em> to <em>DateTimeOffset</em>, and so on) and generates a
<em>CustomScalar</em> class for every scalar it does not recognise. <em>scalarMappings</em> lets
you override both halves of that:
</p>
<ul>
<li>
Map a scalar to a <strong>simple type</strong> and it is emitted as that type - no
<em>CustomScalar</em> class is generated for it. <em>&quot;BigInt&quot;: &quot;long&quot;</em>
gives you <em>public long? Id { get; set; }</em>.
</li>
<li>
Change an existing mapping the same way. <em>&quot;DateTime&quot;:
&quot;System.DateTime&quot;</em> emits <em>DateTime</em> instead of the default
<em>DateTimeOffset</em>.
</li>
<li>
Map a scalar to <strong>null</strong> to opt it out of the built-in mapping, so a
<em>CustomScalar</em> class is generated for it instead and you control the conversion
yourself.
</li>
</ul>
<p>
Scalar names are matched case insensitively. The target must be one of the supported simple types
- <em>bool</em>, <em>byte</em>, <em>sbyte</em>, <em>char</em>, <em>short</em>, <em>ushort</em>,
<em>int</em>, <em>uint</em>, <em>long</em>, <em>ulong</em>, <em>float</em>, <em>double</em>,
<em>decimal</em>, <em>string</em>, <em>Guid</em>, <em>Uri</em>, <em>DateTime</em>,
<em>DateTimeOffset</em>, <em>DateOnly</em>, <em>TimeOnly</em>, <em>TimeSpan</em> - written either
as the C# keyword (<em>long</em>), the type name (<em>Int64</em>) or its full name
(<em>System.Int64</em>). Anything else fails the run with an error rather than generating a
client that does not compile.
</p>
<h2 id="add-nuget">Add Nuget</h2>
<p>Add the Nuget Package <a href="https://www.nuget.org/packages/Linq2GraphQL.Client">Linq2GraphQL.Client</a></p>
<pre><code>dotnet add package Linq2GraphQL.Client --prerelease
Expand Down
22 changes: 16 additions & 6 deletions scripts/regenerate-test-clients.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -125,17 +125,27 @@ try {

$paths = $clients | ForEach-Object { $_.Output }

# git status refreshes the index first, so files rewritten with identical
# content are not reported as changed just because their mtime moved.
$drifted = @(git status --porcelain -- @paths)
$diff = git diff --stat -- @paths
# Compare normalized content rather than the bytes on disk. The generator always
# writes LF, while .gitattributes "text=auto" gives a CRLF working tree on Windows,
# so git status reports every regenerated file as modified even when its content is
# unchanged. git diff applies the same normalization git would apply on checkin, so
# it reports only real drift.
$diff = @(git diff --stat -- @paths)

# git diff only compares tracked files, so a newly generated file needs its own check.
$added = @(git ls-files --others --exclude-standard -- @paths)

git checkout -- @paths

if ($drifted.Count -gt 0) {
if ($diff.Count -gt 0 -or $added.Count -gt 0) {
Write-Host ''
Write-Host 'The checked-in test clients do not match the generator output:' -ForegroundColor Red
Write-Host ($diff -join [Environment]::NewLine)
if ($diff.Count -gt 0) {
Write-Host ($diff -join [Environment]::NewLine)
}
foreach ($file in $added) {
Write-Host "new file: $file"
}
Write-Host ''
Write-Host 'Run ./scripts/regenerate-test-clients.ps1 and commit the result.' -ForegroundColor Red
exit 1
Expand Down
16 changes: 14 additions & 2 deletions src/Linq2GraphQL.Generator/ClientGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,11 @@ public class ClientGenerator(
bool includeSubscriptions,
EnumGeneratorStrategy enumGeneratorStrategy,
bool nullable,
bool includeDeprecated)
bool includeDeprecated,
IReadOnlyDictionary<string, (string Name, Type type)?> scalarMappings = null)
{
private static readonly Dictionary<string, (string Name, Type type)?> EmptyScalarMappings = new();

private readonly List<FileEntry> entries = new();

private void AddFile(string directory, string fileName, string content)
Expand Down Expand Up @@ -74,7 +77,16 @@ public List<FileEntry> Generate(string schemaJson)
{
entries.Clear();

GeneratorSettings.Current = new GeneratorSettings { Nullable = nullable };
var typeMapping = Helpers.CreateTypeMapping();
Helpers.ApplyScalarMappings(typeMapping, scalarMappings);
GeneratorSettings.Current = new GeneratorSettings { Nullable = nullable, TypeMapping = typeMapping };

foreach (var scalarMapping in scalarMappings ?? EmptyScalarMappings)
{
Console.WriteLine(scalarMapping.Value == null
? $"Scalar {scalarMapping.Key} opted out of type mapping, generating a CustomScalar for it"
: $"Scalar {scalarMapping.Key} mapped to {scalarMapping.Value.Value.Name}");
}

var rootSchema = JsonSerializer.Deserialize<RootSchema>(schemaJson,
new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
Expand Down
Loading
Loading