diff --git a/docs/design/agent-mode.md b/docs/design/agent-mode.md
index 9fef9b01..7982df69 100644
--- a/docs/design/agent-mode.md
+++ b/docs/design/agent-mode.md
@@ -1,6 +1,6 @@
# Linq2GraphQL Agent Mode Design
-**Status:** in progress. Plan steps 1-3 (the groundwork, SDL input and SDL output) are implemented. The AI package is being built in slices: (1) package skeleton and scoped schema, (2) renderer, (3) validator, (4) the four tools, (5) the `AsAgentTools` adapter, (6) sample and regression harness. Slice 1 is merged; slice 2 is in review. See [Plan](#plan-and-risks).
+**Status:** in progress. Plan steps 1-3 (the groundwork, SDL input and SDL output) are implemented. The AI package is being built in slices: (1) package skeleton and scoped schema, (2) renderer, (3) validator, (4) the four tools, (5) the `AsAgentTools` adapter, (6) sample and regression harness. Slices 1 and 2 are merged; slice 3 is in review. See [Plan](#plan-and-risks).
## Summary
@@ -292,7 +292,7 @@ The starter query is generated from the scoped schema at no cost, and next to de
Example flow for "which customers have unpaid invoices?": the agent sees `customers` in the index and calls `describe_operation(["customers"])` for its arguments and a starter query. It calls `search_schema("invoice")`, finds `Customer.invoices` and `Invoice.status`, and calls `describe_type(["Invoice", "InvoiceStatus"])`. Then it adapts the starter query and calls `graphql_query`.
-**Validation.** Every query is checked against the scoped schema before it is sent: root fields must be allowed, and every selected field must exist on its type and not be omitted. The validator parses the query and walks it type by type, following fragments and inline fragments through their type conditions (interfaces and unions). Aliases don't matter, because the check uses real field names. Errors name the field and list what is available, for example "`legacyCode` is not available on `Customer`. Available: id, name, status, orders", so the agent can correct itself without a server round-trip. Arguments, input objects (including filters) and enum values are validated in the same walk in the first version; a depth limit can come later.
+**Validation.** Every query is checked against the scoped schema before it is sent: root fields must be allowed, and every selected field must exist on its type and not be omitted. The validator parses the query and walks it type by type, following fragments and inline fragments through their type conditions (interfaces and unions). Aliases don't matter, because the check uses real field names. Errors name the field and list what is available, for example "`legacyCode` is not available on `Customer`. Available: id, name, status, orders", so the agent can correct itself without a server round-trip. Arguments, input objects (including filters) and enum values are validated in the same walk in the first version, and so are variable values, so an omitted field cannot be passed in through a variable either. A depth limit can come later.
## Path to dedicated tools
diff --git a/src/Linq2GraphQL.Client.AI/Schema/QueryValidator.cs b/src/Linq2GraphQL.Client.AI/Schema/QueryValidator.cs
new file mode 100644
index 00000000..61ec7e25
--- /dev/null
+++ b/src/Linq2GraphQL.Client.AI/Schema/QueryValidator.cs
@@ -0,0 +1,613 @@
+using System.Text.Json;
+using GraphQLParser;
+using GraphQLParser.AST;
+using GraphQLParser.Exceptions;
+
+namespace Linq2GraphQL.Client.AI.Schema;
+
+///
+/// One problem with a query, at a path such as orders(where).customer.status.
+///
+internal sealed record ValidationError(string Path, string Message)
+{
+ public override string ToString() => string.IsNullOrEmpty(Path) ? Message : $"{Path}: {Message}";
+}
+
+internal sealed class ValidationResult(IReadOnlyList errors)
+{
+ public IReadOnlyList Errors { get; } = errors;
+
+ public bool IsValid => Errors.Count == 0;
+
+ public override string ToString() => string.Join("\n", Errors);
+}
+
+///
+/// Checks a query the agent wrote against the scoped schema before it is sent: one query operation, allowed
+/// roots, fields that exist and aren't omitted, arguments, input objects (filters included), enum values and
+/// variables, with fragments followed through their type conditions. Aliases don't matter because the check
+/// uses real field names. Variable values are checked too, so an omitted field cannot be passed in through
+/// variables. All errors are returned at once, each saying where and what to do instead.
+///
+internal sealed class QueryValidator(ScopedSchema schema)
+{
+ private const int MaxListedNames = 15;
+
+ private readonly SchemaModel model = schema.Schema;
+ private readonly SchemaShape shape = new(schema.Schema);
+
+ public ValidationResult Validate(string query, JsonElement? variables = null)
+ {
+ var run = new Run(this);
+ run.Validate(query, variables);
+ return new ValidationResult(run.Errors);
+ }
+
+ private sealed class Run(QueryValidator validator)
+ {
+ private readonly SchemaModel model = validator.model;
+ private readonly SchemaShape shape = validator.shape;
+ private readonly Dictionary fragments = new();
+ private readonly Dictionary declaredVariables = new();
+
+ public List Errors { get; } = [];
+
+ public void Validate(string query, JsonElement? variables)
+ {
+ if (string.IsNullOrWhiteSpace(query))
+ {
+ Error("", "The query is empty.");
+ return;
+ }
+
+ GraphQLDocument document;
+ try
+ {
+ document = Parser.Parse(query);
+ }
+ catch (GraphQLParserException ex)
+ {
+ Error("", $"Syntax error: {ex.Message}");
+ return;
+ }
+
+ foreach (var fragment in document.Definitions.OfType())
+ {
+ fragments[Name(fragment.FragmentName.Name)] = fragment;
+ }
+
+ var operations = document.Definitions.OfType().ToList();
+ if (document.Definitions.Any(e => e is not (GraphQLOperationDefinition or GraphQLFragmentDefinition)))
+ {
+ Error("", "Only a query and its fragments can be sent; schema definitions are not allowed.");
+ }
+
+ if (operations.Count != 1)
+ {
+ Error("", operations.Count == 0
+ ? "The document has no operation. Send one query, for example `query { ... }`."
+ : "Send one operation per call.");
+ return;
+ }
+
+ var operation = operations[0];
+ if (operation.Operation != GraphQLParser.AST.OperationType.Query)
+ {
+ Error("", $"Only queries are allowed; this is a {operation.Operation.ToString().ToLowerInvariant()}.");
+ return;
+ }
+
+ ValidateVariableDefinitions(operation, variables);
+ ValidateSelections(model.Type(model.QueryType), operation.SelectionSet, "", []);
+ }
+
+ private void ValidateVariableDefinitions(GraphQLOperationDefinition operation, JsonElement? variables)
+ {
+ foreach (var definition in operation.Variables?.Items ?? [])
+ {
+ var name = Name(definition.Variable.Name);
+ var type = TypeRefOf(definition.Type);
+ var path = "$" + name;
+
+ if (model.Type(type.NamedType) is not { } namedType)
+ {
+ Error(path, $"Type {type.NamedType} is not available.{Suggest(type.NamedType, InputTypeNames())}");
+ continue;
+ }
+
+ if (namedType.Kind is not (SchemaTypeKind.Scalar or SchemaTypeKind.Enum or SchemaTypeKind.InputObject))
+ {
+ Error(path, $"{namedType.Name} is an output type and cannot be a variable.");
+ continue;
+ }
+
+ declaredVariables[name] = type;
+
+ if (definition.DefaultValue != null)
+ {
+ ValidateValue(definition.DefaultValue, type, path);
+ }
+
+ if (variables is { ValueKind: JsonValueKind.Object } values && values.TryGetProperty(name, out var value))
+ {
+ ValidateJson(value, type, path);
+ }
+ else if (type.IsNonNull && definition.DefaultValue == null)
+ {
+ Error(path, $"Variable ${name} is required ({type}) but no value was given.");
+ }
+ }
+
+ if (variables is { ValueKind: JsonValueKind.Object } given)
+ {
+ foreach (var property in given.EnumerateObject().Where(e => !declaredVariables.ContainsKey(e.Name)))
+ {
+ Error("$" + property.Name, $"Variable ${property.Name} is not declared in the query.");
+ }
+ }
+ }
+
+ private void ValidateSelections(SchemaType parent, GraphQLSelectionSet selectionSet, string path,
+ HashSet fragmentsInUse)
+ {
+ foreach (var selection in selectionSet.Selections)
+ {
+ switch (selection)
+ {
+ case GraphQLField field:
+ ValidateField(parent, field, path, fragmentsInUse);
+ break;
+ case GraphQLFragmentSpread spread:
+ ValidateFragmentSpread(parent, spread, path, fragmentsInUse);
+ break;
+ case GraphQLInlineFragment inline:
+ ValidateDirectives(inline.Directives, path);
+ var type = inline.TypeCondition == null
+ ? parent
+ : FragmentType(parent, Name(inline.TypeCondition.Type.Name), path);
+ if (type != null)
+ {
+ ValidateSelections(type, inline.SelectionSet, path, fragmentsInUse);
+ }
+
+ break;
+ }
+ }
+ }
+
+ private void ValidateFragmentSpread(SchemaType parent, GraphQLFragmentSpread spread, string path,
+ HashSet fragmentsInUse)
+ {
+ ValidateDirectives(spread.Directives, path);
+
+ var name = Name(spread.FragmentName.Name);
+ if (!fragments.TryGetValue(name, out var fragment))
+ {
+ Error(path, $"Fragment {name} is not defined.");
+ return;
+ }
+
+ if (!fragmentsInUse.Add(name))
+ {
+ Error(path, $"Fragment {name} spreads itself.");
+ return;
+ }
+
+ if (FragmentType(parent, Name(fragment.TypeCondition.Type.Name), path) is { } type)
+ {
+ ValidateSelections(type, fragment.SelectionSet, path, fragmentsInUse);
+ }
+
+ fragmentsInUse.Remove(name);
+ }
+
+ ///
+ /// The type a fragment selects on, if it exists in scope and can apply where it is spread.
+ ///
+ private SchemaType FragmentType(SchemaType parent, string typeName, string path)
+ {
+ if (model.Type(typeName) is not { } type)
+ {
+ Error(path, $"Type {typeName} is not available.");
+ return null;
+ }
+
+ var applies = type.Name == parent.Name ||
+ parent.PossibleTypes.Contains(type.Name) ||
+ type.PossibleTypes.Contains(parent.Name) ||
+ type.PossibleTypes.Intersect(parent.PossibleTypes).Any();
+ if (!applies)
+ {
+ Error(path, $"A fragment on {type.Name} cannot apply to {parent.Name}.");
+ return null;
+ }
+
+ return type;
+ }
+
+ private void ValidateField(SchemaType parent, GraphQLField field, string parentPath,
+ HashSet fragmentsInUse)
+ {
+ var name = Name(field.Name);
+ var path = Join(parentPath, name);
+ ValidateDirectives(field.Directives, path);
+
+ if (name == "__typename")
+ {
+ return;
+ }
+
+ if (name is "__schema" or "__type")
+ {
+ Error(path, "Introspection is not available. Use describe_operation, describe_type and search_schema to explore the schema.");
+ return;
+ }
+
+ var definition = parent.Field(name);
+ if (definition == null)
+ {
+ Error(path, MissingField(parent, name));
+ return;
+ }
+
+ ValidateArguments(definition, field, path);
+
+ var type = model.Type(definition.Type.NamedType);
+ var isLeaf = type.Kind is SchemaTypeKind.Scalar or SchemaTypeKind.Enum;
+
+ if (isLeaf && field.SelectionSet != null)
+ {
+ Error(path, $"{name} is a {type.Name} and has no fields to select. Remove `{{ ... }}`.");
+ }
+ else if (!isLeaf && field.SelectionSet == null)
+ {
+ Error(path, $"{name} returns {SelectionHint(type)}; select fields, for example `{name} {{ {ExampleFields(type)} }}`.");
+ }
+ else if (!isLeaf)
+ {
+ ValidateSelections(type, field.SelectionSet, path, fragmentsInUse);
+ }
+ }
+
+ private string MissingField(SchemaType parent, string name)
+ {
+ if (parent.Name == model.QueryType)
+ {
+ return $"Query {name} is not available.{Suggest(name, parent.Fields.Select(e => e.Name))}";
+ }
+
+ if (shape.ConnectionOf(parent.Name) is { } connection)
+ {
+ var pageField = connection.HasNodes ? "nodes" : "edges { node";
+ var close = connection.HasNodes ? "}" : "} }";
+ return $"This field is paged; select {name} inside `{pageField} {{ {name} {close}`, " +
+ "for example `{ nodes { ... } pageInfo { hasNextPage endCursor } }`.";
+ }
+
+ return parent.Kind == SchemaTypeKind.Union
+ ? $"{parent.Name} is a union; select fields inside `... on {string.Join(" / ", parent.PossibleTypes)} {{ ... }}`."
+ : $"{parent.Name} has no field {name}.{Suggest(name, parent.Fields.Select(e => e.Name))}";
+ }
+
+ private void ValidateArguments(SchemaField definition, GraphQLField field, string path)
+ {
+ var given = field.Arguments?.Items ?? [];
+ foreach (var argument in given)
+ {
+ var name = Name(argument.Name);
+ var argumentPath = $"{path}({name})";
+ var argumentDefinition = definition.Arguments.FirstOrDefault(e => e.Name == name);
+ if (argumentDefinition == null)
+ {
+ Error(argumentPath, $"{definition.Name} has no argument {name}." +
+ Suggest(name, definition.Arguments.Select(e => e.Name)));
+ continue;
+ }
+
+ ValidateValue(argument.Value, argumentDefinition.Type, argumentPath);
+ }
+
+ foreach (var required in definition.Arguments.Where(e =>
+ e.Type.IsNonNull && e.DefaultValue == null && given.All(g => Name(g.Name) != e.Name)))
+ {
+ Error(path, $"Argument {required.Name} ({required.Type}) is required.");
+ }
+ }
+
+ private void ValidateDirectives(GraphQLDirectives directives, string path)
+ {
+ foreach (var directive in directives?.Items ?? [])
+ {
+ var name = Name(directive.Name);
+ if (name is not ("include" or "skip"))
+ {
+ Error(path, $"Directive @{name} is not supported; only @include and @skip are.");
+ }
+ }
+ }
+
+ private void ValidateValue(GraphQLValue value, TypeRef expected, string path)
+ {
+ if (value is GraphQLVariable variable)
+ {
+ var name = Name(variable.Name);
+ if (!declaredVariables.TryGetValue(name, out var declared))
+ {
+ Error(path, $"Variable ${name} is not declared. Declare it, for example `query(${name}: {expected})`.");
+ }
+ else if (declared.NamedType != expected.NamedType)
+ {
+ Error(path, $"Variable ${name} is declared as {declared} but {expected} is expected here.");
+ }
+
+ return;
+ }
+
+ if (value is GraphQLNullValue)
+ {
+ if (expected.IsNonNull)
+ {
+ Error(path, $"A value is required ({expected}).");
+ }
+
+ return;
+ }
+
+ if (expected.IsNonNull)
+ {
+ ValidateValue(value, expected.OfType, path);
+ return;
+ }
+
+ if (expected.IsList)
+ {
+ if (value is GraphQLListValue list)
+ {
+ var index = 0;
+ foreach (var item in list.Values ?? [])
+ {
+ ValidateValue(item, expected.OfType, $"{path}[{index++}]");
+ }
+ }
+ else
+ {
+ ValidateValue(value, expected.OfType, path);
+ }
+
+ return;
+ }
+
+ var type = model.Type(expected.Name);
+ switch (type.Kind)
+ {
+ case SchemaTypeKind.InputObject when value is GraphQLObjectValue objectValue:
+ var fields = objectValue.Fields ?? [];
+ foreach (var field in fields)
+ {
+ ValidateInputField(type, Name(field.Name), path, fieldPath => ValidateValue(field.Value, type.Field(Name(field.Name)).Type, fieldPath));
+ }
+
+ RequireInputFields(type, fields.Select(e => Name(e.Name)), path);
+ break;
+ case SchemaTypeKind.InputObject:
+ Error(path, $"Expected an object for {type.Name}, for example `{{ {ExampleFields(type)} }}`.");
+ break;
+ case SchemaTypeKind.Enum when value is GraphQLEnumValue enumValue:
+ ValidateEnumValue(type, Name(enumValue.Name), path);
+ break;
+ case SchemaTypeKind.Enum when value is GraphQLStringValue text:
+ Error(path, $"{type.Name} values are written without quotes, for example `{text.Value.ToString()}`." +
+ Suggest(text.Value.ToString(), type.EnumValues.Select(e => e.Name)));
+ break;
+ case SchemaTypeKind.Enum:
+ Error(path, $"Expected a {type.Name} value: {Names(type.EnumValues.Select(e => e.Name))}.");
+ break;
+ case SchemaTypeKind.Scalar when !ScalarAccepts(type.Name, value):
+ Error(path, $"Expected a {type.Name} value.");
+ break;
+ }
+ }
+
+ private void ValidateJson(JsonElement value, TypeRef expected, string path)
+ {
+ if (value.ValueKind == JsonValueKind.Null)
+ {
+ if (expected.IsNonNull)
+ {
+ Error(path, $"A value is required ({expected}).");
+ }
+
+ return;
+ }
+
+ if (expected.IsNonNull)
+ {
+ ValidateJson(value, expected.OfType, path);
+ return;
+ }
+
+ if (expected.IsList)
+ {
+ if (value.ValueKind == JsonValueKind.Array)
+ {
+ var index = 0;
+ foreach (var item in value.EnumerateArray())
+ {
+ ValidateJson(item, expected.OfType, $"{path}[{index++}]");
+ }
+ }
+ else
+ {
+ ValidateJson(value, expected.OfType, path);
+ }
+
+ return;
+ }
+
+ var type = model.Type(expected.Name);
+ switch (type.Kind)
+ {
+ case SchemaTypeKind.InputObject when value.ValueKind == JsonValueKind.Object:
+ var names = new List();
+ foreach (var property in value.EnumerateObject())
+ {
+ names.Add(property.Name);
+ ValidateInputField(type, property.Name, path, fieldPath => ValidateJson(property.Value, type.Field(property.Name).Type, fieldPath));
+ }
+
+ RequireInputFields(type, names, path);
+ break;
+ case SchemaTypeKind.InputObject:
+ Error(path, $"Expected an object for {type.Name}.");
+ break;
+ case SchemaTypeKind.Enum when value.ValueKind == JsonValueKind.String:
+ ValidateEnumValue(type, value.GetString(), path);
+ break;
+ case SchemaTypeKind.Enum:
+ Error(path, $"Expected a {type.Name} value as a string: {Names(type.EnumValues.Select(e => e.Name))}.");
+ break;
+ case SchemaTypeKind.Scalar when !ScalarAccepts(type.Name, value.ValueKind):
+ Error(path, $"Expected a {type.Name} value.");
+ break;
+ }
+ }
+
+ private void ValidateInputField(SchemaType type, string name, string path, Action validateValue)
+ {
+ var fieldPath = Join(path, name);
+ if (type.Field(name) == null)
+ {
+ Error(fieldPath, $"{type.Name} has no field {name}.{Suggest(name, type.Fields.Select(e => e.Name))}");
+ return;
+ }
+
+ validateValue(fieldPath);
+ }
+
+ private void RequireInputFields(SchemaType type, IEnumerable given, string path)
+ {
+ var givenNames = given.ToHashSet();
+ foreach (var required in type.Fields.Where(e =>
+ e.Type.IsNonNull && e.DefaultValue == null && !givenNames.Contains(e.Name)))
+ {
+ Error(path, $"{type.Name}.{required.Name} ({required.Type}) is required.");
+ }
+ }
+
+ private void ValidateEnumValue(SchemaType type, string value, string path)
+ {
+ if (type.EnumValues.All(e => e.Name != value))
+ {
+ Error(path, $"{type.Name} has no value {value}.{Suggest(value, type.EnumValues.Select(e => e.Name))}");
+ }
+ }
+
+ private static bool ScalarAccepts(string scalar, GraphQLValue value) => scalar switch
+ {
+ "Int" => value is GraphQLIntValue,
+ "Float" => value is GraphQLIntValue or GraphQLFloatValue,
+ "Boolean" => value is GraphQLBooleanValue,
+ "String" => value is GraphQLStringValue,
+ "ID" => value is GraphQLStringValue or GraphQLIntValue,
+ _ => value is not (GraphQLObjectValue or GraphQLListValue)
+ };
+
+ private static bool ScalarAccepts(string scalar, JsonValueKind kind) => scalar switch
+ {
+ "Int" or "Float" => kind == JsonValueKind.Number,
+ "Boolean" => kind is JsonValueKind.True or JsonValueKind.False,
+ "String" => kind == JsonValueKind.String,
+ "ID" => kind is JsonValueKind.String or JsonValueKind.Number,
+ _ => kind is not (JsonValueKind.Object or JsonValueKind.Array)
+ };
+
+ private string SelectionHint(SchemaType type) =>
+ shape.ConnectionOf(type.Name) is { } connection ? $"a page of {connection.NodeType}" : type.Name;
+
+ private string ExampleFields(SchemaType type)
+ {
+ if (shape.ConnectionOf(type.Name) is { } connection)
+ {
+ var node = model.Type(connection.NodeType);
+ return connection.HasNodes
+ ? $"nodes {{ {ExampleFields(node)} }} pageInfo {{ hasNextPage endCursor }}"
+ : $"edges {{ node {{ {ExampleFields(node)} }} }} pageInfo {{ hasNextPage endCursor }}";
+ }
+
+ if (type.Kind == SchemaTypeKind.Union)
+ {
+ return $"... on {type.PossibleTypes.FirstOrDefault()} {{ ... }}";
+ }
+
+ var leaves = type.Fields
+ .Where(e => e.Arguments.Count == 0 &&
+ model.Type(e.Type.NamedType).Kind is SchemaTypeKind.Scalar or SchemaTypeKind.Enum)
+ .Take(3)
+ .Select(e => e.Name)
+ .ToList();
+ return leaves.Count > 0 ? string.Join(" ", leaves) : "...";
+ }
+
+ private IEnumerable InputTypeNames() => model.Types.Values
+ .Where(e => e.Kind is SchemaTypeKind.Scalar or SchemaTypeKind.Enum or SchemaTypeKind.InputObject)
+ .Select(e => e.Name);
+
+ private void Error(string path, string message) => Errors.Add(new ValidationError(path, message));
+ }
+
+ ///
+ /// " Did you mean `x`?" for a near miss, and the available names, so the agent can fix the query in one go.
+ ///
+ private static string Suggest(string name, IEnumerable candidates)
+ {
+ var list = candidates.ToList();
+ var closest = list
+ .Select(e => (Name: e, Distance: Distance(name.ToLowerInvariant(), e.ToLowerInvariant())))
+ .Where(e => e.Distance <= Math.Max(2, name.Length / 3))
+ .OrderBy(e => e.Distance)
+ .Select(e => e.Name)
+ .FirstOrDefault();
+
+ var suggestion = closest == null ? "" : $" Did you mean `{closest}`?";
+ return list.Count == 0 ? suggestion : $"{suggestion} Available: {Names(list)}.";
+ }
+
+ private static string Names(IEnumerable names)
+ {
+ var list = names.ToList();
+ return list.Count <= MaxListedNames
+ ? string.Join(", ", list)
+ : string.Join(", ", list.Take(MaxListedNames)) + $" and {list.Count - MaxListedNames} more";
+ }
+
+ private static int Distance(string a, string b)
+ {
+ var previous = Enumerable.Range(0, b.Length + 1).ToArray();
+ for (var i = 1; i <= a.Length; i++)
+ {
+ var current = new int[b.Length + 1];
+ current[0] = i;
+ for (var j = 1; j <= b.Length; j++)
+ {
+ var cost = a[i - 1] == b[j - 1] ? 0 : 1;
+ current[j] = Math.Min(Math.Min(current[j - 1] + 1, previous[j] + 1), previous[j - 1] + cost);
+ }
+
+ previous = current;
+ }
+
+ return previous[b.Length];
+ }
+
+ private static string Join(string path, string name) => string.IsNullOrEmpty(path) ? name : $"{path}.{name}";
+
+ private static string Name(GraphQLName name) => name.StringValue;
+
+ private static TypeRef TypeRefOf(GraphQLType type) => type switch
+ {
+ GraphQLNonNullType e => TypeRef.NonNull(TypeRefOf(e.Type)),
+ GraphQLListType e => TypeRef.List(TypeRefOf(e.Type)),
+ GraphQLNamedType e => TypeRef.Named(Name(e.Name)),
+ _ => TypeRef.Named(type.Kind.ToString())
+ };
+}
diff --git a/test/Linq2GraphQL.Tests/QueryValidatorTests.cs b/test/Linq2GraphQL.Tests/QueryValidatorTests.cs
new file mode 100644
index 00000000..6ea3f781
--- /dev/null
+++ b/test/Linq2GraphQL.Tests/QueryValidatorTests.cs
@@ -0,0 +1,197 @@
+using System.Text.Json;
+using Linq2GraphQL.Client.AI.Schema;
+using Linq2GraphQL.TestClient;
+using Shouldly;
+
+namespace Linq2GraphQL.Tests;
+
+///
+/// Covers the validator that checks agent-written queries against the scoped schema before they are sent.
+/// The scope allows hello, customers, orders and animals, and omits Customer.address.
+///
+public class QueryValidatorTests
+{
+ private static readonly QueryValidator Validator = new(ScopedSchema.Create(SampleClient.Sdl, o =>
+ {
+ o.AllowQuery(c => c.Query.Hello);
+ o.AllowQuery(c => c.Query.Customers);
+ o.AllowQuery(c => c.Query.Orders);
+ o.AllowQuery(c => c.Query.Animals);
+ o.Omit(c => c.Address);
+ }));
+
+ private static ValidationResult Validate(string query, string? variables = null) =>
+ Validator.Validate(query, variables == null ? null : JsonDocument.Parse(variables).RootElement);
+
+ private static void ShouldBeValid(string query, string? variables = null)
+ {
+ var result = Validate(query, variables);
+ result.IsValid.ShouldBeTrue(result.ToString());
+ }
+
+ private static string ShouldFail(string query, string? variables = null)
+ {
+ var result = Validate(query, variables);
+ result.IsValid.ShouldBeFalse();
+ return result.ToString();
+ }
+
+ ///
+ /// The validator must not accept what the server rejects: every query it passes runs without errors.
+ ///
+ [Theory]
+ [InlineData("{ hello }")]
+ [InlineData("query { hello(name: \"Jocke\") }")]
+ [InlineData("{ customers { customerName status orders { orderId lines { quantity } } } }")]
+ [InlineData("{ c: customers { name: customerName } __typename }")]
+ [InlineData("query { customers { ...C } } fragment C on Customer { customerName relatedCustomer(relationType: 1) { customerId } }")]
+ [InlineData("{ animals(first: 5) { nodes { name ... on Pig { spices } ... on Spider { poisonous } } pageInfo { hasNextPage endCursor } totalCount } }")]
+ [InlineData("{ orders(where: { customer: { status: { eq: ACTIVE } }, or: [{ orderDate: { gt: \"2024-01-01T00:00:00Z\" } }] }, order: [{ orderDate: DESC }]) { nodes { orderId } } }")]
+ [InlineData("{ orders(where: { lines: { some: { quantity: { gte: 2 } } } }) { edges { cursor node { orderId } } } }")]
+ [InlineData("{ customers { customerName @include(if: true) } }")]
+ public async Task ValidQueries_AreAcceptedByTheServer(string query)
+ {
+ ShouldBeValid(query);
+
+ using var fixture = new SampleClientFixture();
+ var result = await fixture.sampleClient.GraphClient.ExecuteAsync(query);
+
+ result.HasErrors.ShouldBeFalse(string.Join("; ", result.Errors?.Select(e => e.Message) ?? []));
+ }
+
+ [Fact]
+ public void FilterInVariables_Passes()
+ {
+ ShouldBeValid("query($w: OrderFilterInput, $n: Int) { orders(where: $w, first: $n) { nodes { orderId } } }",
+ """{ "w": { "customer": { "customerName": { "contains": "A" }, "status": { "in": ["ACTIVE"] } } }, "n": 10 }""");
+ }
+
+ [Fact]
+ public void Mutation_IsRejected()
+ {
+ ShouldFail("mutation { setName(name: \"x\") }").ShouldContain("Only queries are allowed; this is a mutation.");
+ }
+
+ [Fact]
+ public void RootNotAllowed_ListsTheAvailableQueries()
+ {
+ ShouldFail("{ raiseError }").ShouldContain("Query raiseError is not available. Available: hello, customers, orders, animals.");
+ }
+
+ [Fact]
+ public void Typo_SuggestsTheClosestName()
+ {
+ ShouldFail("{ custmers { customerName } }").ShouldContain("Did you mean `customers`?");
+ ShouldFail("{ customers { custmerName } }").ShouldContain("Customer has no field custmerName. Did you mean `customerName`?");
+ }
+
+ [Fact]
+ public void OmittedField_CannotBeSelected()
+ {
+ var errors = ShouldFail("{ customers { address { name } } }");
+
+ errors.ShouldContain("customers.address: Customer has no field address.");
+ errors.ShouldNotContain("Available: customerId, customerName, status, orders, address");
+ }
+
+ [Fact]
+ public void OmittedField_CannotBeFilteredOn()
+ {
+ ShouldFail("{ orders(where: { customer: { address: { name: { eq: \"x\" } } } }) { nodes { orderId } } }")
+ .ShouldContain("orders(where).customer.address: CustomerFilterInput has no field address.");
+ }
+
+ [Fact]
+ public void OmittedField_CannotBeFilteredOnThroughVariables()
+ {
+ ShouldFail("query($w: OrderFilterInput) { orders(where: $w) { nodes { orderId } } }",
+ """{ "w": { "customer": { "address": { "name": { "eq": "x" } } } } }""")
+ .ShouldContain("$w.customer.address: CustomerFilterInput has no field address.");
+ }
+
+ [Fact]
+ public void OmittedField_CannotBeSortedOn()
+ {
+ ShouldFail("{ orders(order: [{ customer: { address: { name: ASC } } }]) { nodes { orderId } } }")
+ .ShouldContain("CustomerSortInput has no field address.");
+ }
+
+ [Fact]
+ public void PagedField_SelectedDirectly_GetsTheFix()
+ {
+ ShouldFail("{ orders { orderId } }")
+ .ShouldContain("orders.orderId: This field is paged; select orderId inside `nodes { orderId }`");
+ }
+
+ [Fact]
+ public void LeafAndCompositeSelections_AreChecked()
+ {
+ ShouldFail("{ hello { length } }").ShouldContain("hello is a String and has no fields to select.");
+ ShouldFail("{ customers }").ShouldContain("customers returns Customer; select fields, for example `customers { customerId customerName status }`.");
+ ShouldFail("{ orders }").ShouldContain("orders returns a page of Order; select fields, for example `orders { nodes { orderId orderDate orderDateTime } pageInfo { hasNextPage endCursor } }`.");
+ }
+
+ [Fact]
+ public void EnumValues_AreChecked()
+ {
+ ShouldFail("{ orders(where: { customer: { status: { eq: \"ACTIVE\" } } }) { nodes { orderId } } }")
+ .ShouldContain("CustomerStatus values are written without quotes");
+ ShouldFail("{ orders(where: { customer: { status: { eq: ACTIV } } }) { nodes { orderId } } }")
+ .ShouldContain("CustomerStatus has no value ACTIV. Did you mean `ACTIVE`?");
+ }
+
+ [Fact]
+ public void Introspection_IsBlocked()
+ {
+ ShouldFail("{ __schema { types { name } } }").ShouldContain("Introspection is not available");
+ ShouldFail("{ __type(name: \"Customer\") { name } }").ShouldContain("Introspection is not available");
+ }
+
+ [Fact]
+ public void Arguments_AreChecked()
+ {
+ ShouldFail("{ orders(pageSize: 5) { nodes { orderId } } }").ShouldContain("orders has no argument pageSize.");
+ ShouldFail("{ customers { relatedCustomers { customerName } } }")
+ .ShouldContain("Argument relationType (Int!) is required.");
+ ShouldFail("{ orders(first: \"five\") { nodes { orderId } } }").ShouldContain("orders(first): Expected a Int value.");
+ }
+
+ [Fact]
+ public void Variables_AreChecked()
+ {
+ ShouldFail("{ orders(where: $w) { nodes { orderId } } }").ShouldContain("Variable $w is not declared.");
+ ShouldFail("query($w: String) { orders(where: $w) { nodes { orderId } } }")
+ .ShouldContain("Variable $w is declared as String but OrderFilterInput is expected here.");
+ ShouldFail("query($w: SecretFilterInput) { orders(where: $w) { nodes { orderId } } }")
+ .ShouldContain("Type SecretFilterInput is not available.");
+ ShouldFail("query($n: Int!) { orders(first: $n) { nodes { orderId } } }")
+ .ShouldContain("Variable $n is required (Int!) but no value was given.");
+ ShouldFail("{ hello }", """{ "extra": 1 }""").ShouldContain("Variable $extra is not declared in the query.");
+ }
+
+ [Fact]
+ public void Fragments_AreChecked()
+ {
+ ShouldFail("{ customers { ... on Order { orderId } } }").ShouldContain("A fragment on Order cannot apply to Customer.");
+ ShouldFail("{ customers { ...Missing } }").ShouldContain("Fragment Missing is not defined.");
+ ShouldFail("{ customers { ...A } } fragment A on Customer { ...A }").ShouldContain("Fragment A spreads itself.");
+ ShouldFail("{ animals { nodes { ... on Pig { secret } } } }").ShouldContain("Pig has no field secret.");
+ }
+
+ [Fact]
+ public void Document_IsChecked()
+ {
+ ShouldFail("{ hello").ShouldContain("Syntax error");
+ ShouldFail("query A { hello } query B { hello }").ShouldContain("Send one operation per call.");
+ ShouldFail("fragment F on Customer { customerName }").ShouldContain("The document has no operation.");
+ ShouldFail("{ hello @custom }").ShouldContain("Directive @custom is not supported");
+ }
+
+ [Fact]
+ public void AllErrors_AreReportedAtOnce()
+ {
+ var result = Validate("{ custmers { x } hello { y } raiseError }");
+
+ result.Errors.Count.ShouldBe(3);
+ }
+}