Skip to content

Commit cac7062

Browse files
report the schema argument name and the real cause on argument errors
Two error messages sent an argument-coercion failure looking in entirely the wrong place. DataAnnotationsValidator reported a missing required argument by its dotnet property name ("missing required argument 'From'") while ArgType reported the same thing by its schema name ('from'), so one query could produce both spellings. The capitalised name reads as the schema no longer applying SchemaFieldNamer to arguments, which it still does. "Variable or value used for argument 'x' does not match argument type 'Y'" names GraphQL types on both sides - frequently the same type - while the failure is between two dotnet types, and the underlying exception was caught and discarded. The cause is now appended when the schema is in development mode or the exception is allowed, leaving production messages unchanged. Also documents that AddTypeMapping is output-only when the target scalar is registered against a different dotnet type: a variable of that scalar is built as the scalar's type and cannot feed an argument of the mapped type. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent d529ab4 commit cac7062

9 files changed

Lines changed: 198 additions & 5 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
## Fixes
44

55
- Fixed `ResolveBulk` fields selected through a fragment spread or inline fragment failing with `KeyNotFoundException` (`The given key 'bulk_Type.field' was not present in the dictionary`) anywhere below a list-to-single selection - e.g. a mutation returning `ctx.Things.First(...)` selected as `thing { child { ...ChildFields } }`, or `thing(id: 1) { ...ThingFields }`. Apollo/Relay clients hoist selections into named fragments, so this showed up as soon as a bulk field was used from a real frontend. A fragment spread is parsed with the parent of the node it is spread on, so the "is this field selected directly on a list-to-single node?" check read the wrong node: bulk loading was skipped for the field while the second pass still compiled the lookup, leaving it reading data that was never loaded. Both the registration and the lookup now ask the compile context which node is compiling its selection set, so they always agree, and the per-compile copy of a list-to-single node keeps its to-single marker instead of looking like a plain list selection. A bulk field selected via a fragment directly on a list-to-single field now uses the per-item resolver, matching how it already behaved when selected directly. Repro and regression tests from [#536](https://github.com/EntityGraphQL/EntityGraphQL/pull/536) - thanks [@alex-birch](https://github.com/alex-birch).
6+
- A missing required argument on a field taking an arguments class is now reported by its schema name instead of its dotnet property name - `missing required argument 'from'`, not `'From'`. `ArgType` already used the schema name, so the two validation paths disagreed and a query missing one argument could produce both spellings. Reading the capitalised name naturally suggests the schema stopped applying `SchemaFieldNamer` to arguments, which sends you looking in the wrong place entirely.
7+
- `Variable or value used for argument 'x' does not match argument type 'Y' on field 'z'` now carries the reason the value could not be built (e.g. `Object must implement IConvertible.`) when the schema is in development mode or the exception is allowed via `AllowedExceptions`/`AllowedExceptionAttribute`. Both types in that message are GraphQL types and are frequently the same on each side, so the message could describe a conversion failing between two dotnet types while naming neither - the underlying exception was caught and discarded. Messages outside development are unchanged. `ISchemaProvider.IsAllowedException(Exception)` is new (it was already the private gate behind `AllowedExceptionMessage`) - custom `ISchemaProvider` implementations need to add it.
68

79
# 6.1.0
810

‎docs/docs/schema-creation/scalar-types.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,22 @@ decimal -> Float
7070
byte[] -> String
7171
```
7272

73+
:::caution
74+
`AddTypeMapping` is one-way - it tells EntityGraphQL which GraphQL type to _write_ for a dotnet type. The reverse
75+
lookup, used to decide the dotnet type a query document variable is built as, comes from the scalar's own dotnet
76+
type. So if you map a dotnet type onto a scalar registered against a _different_ dotnet type, output works but a
77+
variable used for an argument of the mapped type arrives as the scalar's type instead:
78+
79+
```cs
80+
schema.AddScalarType<Instant>("Date", "Date/time scalar");
81+
// DateTime fields now write as Date, but `$from: Date!` builds an Instant, not a DateTime
82+
schema.AddTypeMapping<DateTime>("Date!");
83+
```
84+
85+
Register a [custom type converter](#custom-type-converters) for the pair so arguments of the mapped type can still
86+
be built (here `Instant` -> `DateTime`), or give each dotnet type its own scalar name.
87+
:::
88+
7389
## Custom Type Converters
7490

7591
EntityGraphQL provides a type converter system that enables runtime conversion between types. This is useful for:

‎docs/docs/upgrade-6-0.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,20 @@ type Person {
276276
2. If you have custom schema introspection or code generation tools, update them to recognize `DateTime`
277277
3. The CLR type remains `System.DateTime` - only the GraphQL scalar name has changed
278278

279+
If clients can't be updated (e.g. a public API), keep the old name by re-registering the scalar against
280+
`DateTime`:
281+
282+
```cs
283+
schema.RemoveType("DateTime");
284+
schema.AddScalarType<DateTime>("Date", "Date with time scalar");
285+
```
286+
287+
:::caution
288+
Don't register `Date` against a different CLR type and map `DateTime` onto it with `AddTypeMapping<DateTime>("Date!")`
289+
- output works, but a variable declared as `Date!` is then built as that other type and can't be used for a
290+
`DateTime` argument. See [Scalar types](schema-creation/scalar-types).
291+
:::
292+
279293
## Authorization Refactoring
280294

281295
The authorization system has been refactored to use a keyed data structure for better extensibility. This allows any package to add custom authorization requirements without modifying core classes. **Role-based authorization methods are now extension methods**, following the same pattern as policy-based authorization.

‎src/EntityGraphQL/Compiler/Util/ArgumentUtil.cs‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,9 +91,14 @@ HashSet<string> validationErrors
9191
if (field != null)
9292
argField.ValidateAsync(val, field, validationErrors).GetAwaiter().GetResult();
9393
}
94-
catch (Exception)
94+
catch (Exception ex)
9595
{
96-
validationErrors.Add($"Variable or value used for argument '{argField.Name}' does not match argument type '{argField.Type}' on field '{fieldName}'");
96+
// the GraphQL types on both sides of this message are often the same, so on their own they say
97+
// nothing about why the value could not be built - carry the cause when we're allowed to show it
98+
var message = $"Variable or value used for argument '{argField.Name}' does not match argument type '{argField.Type}' on field '{fieldName}'";
99+
if (schema.IsAllowedException(ex))
100+
message = $"{message} - {ex.Message}";
101+
validationErrors.Add(message);
97102
}
98103
}
99104

‎src/EntityGraphQL/Schema/ISchemaProvider.cs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,4 +113,10 @@ void AddMutationsFrom<TType>(SchemaBuilderOptions? options = null)
113113
ISchemaType CheckTypeAccess(ISchemaType schemaType, QueryRequestContext? requestContext);
114114
IEnumerable<GraphQLError> GenerateErrors(Exception exception, string? fieldName = null);
115115
string AllowedExceptionMessage(Exception exception, string? fieldName = null);
116+
117+
/// <summary>
118+
/// True if the exception's message may be shown to the caller - the schema is in development mode, or the
119+
/// exception type is registered in AllowedExceptions / marked with AllowedExceptionAttribute.
120+
/// </summary>
121+
bool IsAllowedException(Exception exception);
116122
}

‎src/EntityGraphQL/Schema/SchemaProvider.cs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -620,7 +620,7 @@ public string AllowedExceptionMessage(Exception exception, string? fieldName = n
620620
return message;
621621
}
622622

623-
private bool IsAllowedException(Exception exception) =>
623+
public bool IsAllowedException(Exception exception) =>
624624
isDevelopment || AllowedExceptions.Any(e => e.IsAllowed(exception)) || exception.GetType().GetCustomAttribute<AllowedExceptionAttribute>() != null;
625625

626626
private GraphQLDocument CompileQueryWithCache(QueryRequest gql, ExecutionOptions executionOptions)

‎src/EntityGraphQL/Schema/Validators/DataAnnotationsValidator.cs‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ private static void ValidateObjectRecursive(ArgumentValidatorContext context, ob
105105
{
106106
if (value == null)
107107
{
108-
context.AddError($"missing required argument '{property.Name}'");
108+
context.AddError($"missing required argument '{SchemaName(context, property.Name)}'");
109109
}
110110
continue;
111111
}
@@ -124,4 +124,18 @@ private static void ValidateObjectRecursive(ArgumentValidatorContext context, ob
124124
}
125125
}
126126
}
127+
128+
/// <summary>
129+
/// The name a CLR member is exposed as in the schema. A field argument carries its dotnet name so it can be
130+
/// matched exactly; members of a nested input object are not arguments, so fall back to the schema's namer.
131+
/// </summary>
132+
private static string SchemaName(ArgumentValidatorContext context, string memberName)
133+
{
134+
foreach (var arg in context.Field.Arguments.Values)
135+
{
136+
if (arg.DotnetName == memberName)
137+
return arg.Name;
138+
}
139+
return context.Field.Schema.SchemaFieldNamer(memberName);
140+
}
127141
}

‎src/tests/EntityGraphQL.Tests/MutationTests/MutationTests.cs‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,11 @@ id name lastName
217217
var result = schemaProvider.ExecuteRequestWithContext(gql, new TestDataContext(), null, null);
218218
Assert.NotNull(result.Errors);
219219
Assert.Single(result.Errors);
220-
Assert.Equal("Variable or value used for argument 'nameInput' does not match argument type 'InputObject' on field 'addPersonInput'", result.Errors.First().Message);
220+
// the schema is in development mode, so the message carries the cause of the failed conversion
221+
Assert.Equal(
222+
"Variable or value used for argument 'nameInput' does not match argument type 'InputObject' on field 'addPersonInput' - Object must implement IConvertible.",
223+
result.Errors.First().Message
224+
);
221225
}
222226

223227
[Fact]
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
using System;
2+
using System.Globalization;
3+
using System.Linq;
4+
using EntityGraphQL.Schema;
5+
using EntityGraphQL.Schema.FieldExtensions;
6+
using Xunit;
7+
8+
namespace EntityGraphQL.Tests;
9+
10+
public class ArgumentErrorMessageTests
11+
{
12+
/// <summary>
13+
/// Stand in for a CLR type (e.g. NodaTime's Instant) that a scalar is registered against while another CLR
14+
/// type is mapped onto the same GQL type name with AddTypeMapping.
15+
/// </summary>
16+
public readonly struct MyInstant(DateTime utc)
17+
{
18+
public DateTime Utc { get; } = utc;
19+
}
20+
21+
#nullable disable
22+
internal class HistoricalSightingArgs : OffsetArgs
23+
{
24+
public RequiredField<Guid> FloorId { get; set; }
25+
public RequiredField<DateTime> From { get; set; }
26+
public RequiredField<DateTime> To { get; set; }
27+
}
28+
29+
#nullable enable
30+
31+
private static SchemaProvider<TestDataContext> BuildSchema(bool isDevelopment = true)
32+
{
33+
var schema = SchemaBuilder.FromObject<TestDataContext>(new SchemaProviderOptions { IsDevelopment = isDevelopment });
34+
schema
35+
.Query()
36+
.AddField("historicalSightings", new HistoricalSightingArgs(), "Historical sightings")
37+
.Resolve((db, args) => db.Projects.Where(p => p.Id > 0).Skip(args.Skip ?? 0).Take(args.Take ?? 10).ToList());
38+
return schema;
39+
}
40+
41+
/// <summary>
42+
/// Registers "Date" against MyInstant and maps DateTime onto the same GQL type name, so a variable declared
43+
/// as Date! materializes as a MyInstant while the field's argument is a DateTime.
44+
/// </summary>
45+
private static SchemaProvider<TestDataContext> BuildSchemaWithMismatchedDateScalar(bool isDevelopment)
46+
{
47+
var schema = BuildSchema(isDevelopment);
48+
schema.AddCustomTypeConverter(
49+
(TypeConverterTryFrom<string>)(
50+
(string str, Type toType, ISchemaProvider _, out object? result) =>
51+
{
52+
if (toType == typeof(MyInstant) || toType == typeof(MyInstant?))
53+
{
54+
result = new MyInstant(DateTime.Parse(str, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal));
55+
return true;
56+
}
57+
result = null;
58+
return false;
59+
}
60+
)
61+
);
62+
schema.AddScalarType<MyInstant>("Date", "Date/time scalar");
63+
schema.RemoveType("DateTime");
64+
schema.AddTypeMapping<DateTime>("Date!");
65+
schema.AddTypeMapping<DateTime?>("Date");
66+
return schema;
67+
}
68+
69+
private static QueryRequest HistoricalSightingsQuery() =>
70+
new()
71+
{
72+
Query =
73+
@"query GetHistoricalSightings($floorId: ID!, $from: Date!, $to: Date!) {
74+
historicalSightings(floorId: $floorId, from: $from, to: $to) { id name }
75+
}",
76+
Variables = new QueryVariables
77+
{
78+
{ "floorId", "cccccccc-bbbb-4444-1111-ccddeeff0033" },
79+
{ "from", "2026-05-03T22:00:00.000Z" },
80+
{ "to", "2026-05-03T23:00:00.000Z" },
81+
},
82+
};
83+
84+
[Fact]
85+
public void ArgumentsFromAnArgsClassUseTheSchemaFieldNamer()
86+
{
87+
var sdl = BuildSchema().ToGraphQLSchemaString();
88+
Assert.Contains("floorId: ID!", sdl);
89+
Assert.Contains("from: DateTime!", sdl);
90+
Assert.Contains("to: DateTime!", sdl);
91+
}
92+
93+
[Fact]
94+
public void MissingRequiredArgumentFromAnArgsClassReportsTheSchemaArgumentName()
95+
{
96+
var gql = new QueryRequest { Query = @"{ historicalSightings { id } }" };
97+
98+
var result = BuildSchema().ExecuteRequestWithContext(gql, new TestDataContext().FillWithTestData(), null, null);
99+
100+
Assert.NotNull(result.Errors);
101+
// the dotnet properties are FloorId/From/To - each must be reported once, by its schema name
102+
Assert.Equal(3, result.Errors.Count);
103+
Assert.Contains("Field 'historicalSightings' - missing required argument 'floorId'", result.Errors.Select(e => e.Message));
104+
Assert.Contains("Field 'historicalSightings' - missing required argument 'from'", result.Errors.Select(e => e.Message));
105+
Assert.Contains("Field 'historicalSightings' - missing required argument 'to'", result.Errors.Select(e => e.Message));
106+
}
107+
108+
[Fact]
109+
public void ArgumentBuildFailureCarriesTheCauseInDevelopment()
110+
{
111+
var schema = BuildSchemaWithMismatchedDateScalar(isDevelopment: true);
112+
113+
var result = schema.ExecuteRequestWithContext(HistoricalSightingsQuery(), new TestDataContext().FillWithTestData(), null, null);
114+
115+
Assert.NotNull(result.Errors);
116+
var expectedPrefix = "Variable or value used for argument 'from' does not match argument type 'Date!' on field 'historicalSightings' - ";
117+
var message = Assert.Single(result.Errors.Select(e => e.Message), m => m.StartsWith(expectedPrefix));
118+
// the GQL types alone say nothing - the cause is a MyInstant value that can not become a DateTime
119+
Assert.Contains("IConvertible", message);
120+
}
121+
122+
[Fact]
123+
public void ArgumentBuildFailureHidesTheCauseWhenNotInDevelopment()
124+
{
125+
var schema = BuildSchemaWithMismatchedDateScalar(isDevelopment: false);
126+
127+
var result = schema.ExecuteRequestWithContext(HistoricalSightingsQuery(), new TestDataContext().FillWithTestData(), null, null);
128+
129+
Assert.NotNull(result.Errors);
130+
Assert.Contains("Variable or value used for argument 'from' does not match argument type 'Date!' on field 'historicalSightings'", result.Errors.Select(e => e.Message));
131+
}
132+
}

0 commit comments

Comments
 (0)