diff --git a/gql/lib/src/validation/rules/executable_definitions.dart b/gql/lib/src/validation/rules/executable_definitions.dart new file mode 100644 index 000000000..3669d589e --- /dev/null +++ b/gql/lib/src/validation/rules/executable_definitions.dart @@ -0,0 +1,50 @@ +import "package:gql/document.dart"; +import "package:gql/src/validation/validating_visitor.dart"; + +import "../../../ast.dart"; + +class NonExecutableError extends ValidationError { + const NonExecutableError({required String name, required DefinitionNode node}) + : super( + message: 'The "$name" definition is not executable.', + node: node, + ); + + const NonExecutableError.schema({required DefinitionNode node}) + : super( + message: "The schema definition is not executable.", + node: node, + ); +} + +/// Executable definitions +/// +/// A GraphQL document is only valid for execution if all definitions are either +/// operation or fragment definitions. +/// +/// See https://spec.graphql.org/draft/#sec-Executable-Definitions +class ExecutableDefinitions extends ValidatingVisitor { + @override + List? visitDocumentNode(DocumentNode node) => + node.definitions + .map((it) { + if (it is ExecutableDefinitionNode) { + return null; + } else { + if (it is TypeDefinitionNode) { + return NonExecutableError(name: it.name.value, node: it); + } else if (it is TypeExtensionNode) { + return NonExecutableError(name: it.name.value, node: it); + } else if (it is DirectiveDefinitionNode) { + return NonExecutableError(name: it.name.value, node: it); + } else if (it is SchemaDefinitionNode || + it is SchemaExtensionNode) { + return NonExecutableError.schema(node: it); + } else { + throw StateError("Invalid node type"); + } + } + }) + .nonNulls + .toList(); +} diff --git a/gql/lib/src/validation/rules/possible_type_extensions.dart b/gql/lib/src/validation/rules/possible_type_extensions.dart new file mode 100644 index 000000000..daea7b504 --- /dev/null +++ b/gql/lib/src/validation/rules/possible_type_extensions.dart @@ -0,0 +1,113 @@ +import "package:gql/ast.dart"; +import "package:gql/document.dart" show ValidationError; +import "package:gql/src/validation/validating_visitor.dart"; + +class PossibleTypeExtensionError extends ValidationError { + PossibleTypeExtensionError.nodeNotDefined( + TypeExtensionNode node, + ) : super( + message: + 'Cannot extend type "${node.name.value}" because it is not defined.', + node: node, + ); + + PossibleTypeExtensionError.incorrectType( + TypeExtensionNode node, { + required String kind, + }) : super( + message: 'Cannot extend non-${kind} type "${node.name.value}".', + node: node, + ); +} + +/// Possible type extension +/// +/// A type extension is only valid if the type is defined and has the same kind. +class PossibleTypeExtensions extends ValidatingVisitor { + final _definedTypes = {}; + + List? _recordType(TypeDefinitionNode node) { + _definedTypes[node.name.value] = node; + return null; + } + + List? _validateType( + TypeExtensionNode node, { + required String kind, + }) { + final typeDefinition = _definedTypes[node.name.value]; + if (typeDefinition == null) { + return [PossibleTypeExtensionError.nodeNotDefined(node)]; + } + if (typeDefinition is! T) { + return [PossibleTypeExtensionError.incorrectType(node, kind: kind)]; + } + return null; + } + + // Enum + @override + List? visitEnumTypeDefinitionNode( + EnumTypeDefinitionNode node) => + _recordType(node); + + @override + List? visitEnumTypeExtensionNode( + EnumTypeExtensionNode node) => + _validateType(node, kind: "enum"); + + // Input Object + @override + List? visitInputObjectTypeDefinitionNode( + InputObjectTypeDefinitionNode node) => + _recordType(node); + + @override + List? visitInputObjectTypeExtensionNode( + InputObjectTypeExtensionNode node) => + _validateType(node, kind: "input"); + + // Union + @override + List? visitUnionTypeDefinitionNode( + UnionTypeDefinitionNode node) => + _recordType(node); + + @override + List? visitUnionTypeExtensionNode( + UnionTypeExtensionNode node) => + _validateType(node, kind: "union"); + + // Interface + @override + List? visitInterfaceTypeDefinitionNode( + InterfaceTypeDefinitionNode node) => + _recordType(node); + + @override + List? visitInterfaceTypeExtensionNode( + InterfaceTypeExtensionNode node) => + _validateType(node, kind: "interface"); + + // Object + @override + List? visitObjectTypeDefinitionNode( + ObjectTypeDefinitionNode node) => + _recordType(node); + + @override + List? visitObjectTypeExtensionNode( + ObjectTypeExtensionNode node) => + _validateType(node, kind: "object"); + + // Scalar + @override + List? visitScalarTypeDefinitionNode( + ScalarTypeDefinitionNode node) => + _recordType(node); + + @override + List? visitScalarTypeExtensionNode( + ScalarTypeExtensionNode node) => + _validateType(node, kind: "scalar"); +} diff --git a/gql/lib/src/validation/rules/unique_argument_definition_names.dart b/gql/lib/src/validation/rules/unique_argument_definition_names.dart new file mode 100644 index 000000000..915799b3b --- /dev/null +++ b/gql/lib/src/validation/rules/unique_argument_definition_names.dart @@ -0,0 +1,83 @@ +import "package:collection/collection.dart"; +import "package:gql/ast.dart"; +import "package:gql/document.dart"; +import "package:gql/src/validation/validating_visitor.dart"; + +class DuplicateArgumentDefinitionNameError extends ValidationError { + const DuplicateArgumentDefinitionNameError( + {required String parentName, required String argumentName, Node? node}) + : super( + message: + 'Argument "${parentName}(${argumentName}:)" can only be defined once.', + node: node, + ); +} + +/// Unique argument definition names +/// +/// A GraphQL Object or Interface type is only valid if all its fields have uniquely named arguments. +/// A GraphQL Directive is only valid if all its arguments are uniquely named. +class UniqueArgumentDefinitionNames extends ValidatingVisitor { + @override + List? visitDirectiveDefinitionNode( + DirectiveDefinitionNode node, + ) => + _checkArgumentUniqueness( + parentName: "@${node.name.value}", + argumentNodes: node.args, + ); + + @override + List? visitInterfaceTypeDefinitionNode( + InterfaceTypeDefinitionNode node, + ) => + _checkArgumentUniquenessPerField(name: node.name, fields: node.fields); + + @override + List? visitInterfaceTypeExtensionNode( + InterfaceTypeExtensionNode node, + ) => + _checkArgumentUniquenessPerField(name: node.name, fields: node.fields); + + @override + List? visitObjectTypeDefinitionNode( + ObjectTypeDefinitionNode node, + ) => + _checkArgumentUniquenessPerField(name: node.name, fields: node.fields); + + @override + List? visitObjectTypeExtensionNode( + ObjectTypeExtensionNode node, + ) => + _checkArgumentUniquenessPerField(name: node.name, fields: node.fields); + + List _checkArgumentUniquenessPerField({ + required NameNode name, + required List fields, + }) => + fields + .expand( + (e) => _checkArgumentUniqueness( + parentName: "${name.value}.${e.name.value}", + argumentNodes: e.args), + ) + .toList(); + + List _checkArgumentUniqueness({ + required String parentName, + required List argumentNodes, + }) => + argumentNodes + .groupListsBy((it) => it.name.value) + .entries + .map( + (e) => e.value.length > 1 + ? DuplicateArgumentDefinitionNameError( + parentName: parentName, + argumentName: e.key, + ) + : null, + ) + .nonNulls + .toList(); +} diff --git a/gql/lib/src/validation/validator.dart b/gql/lib/src/validation/validator.dart index a565186a8..094aea38c 100644 --- a/gql/lib/src/validation/validator.dart +++ b/gql/lib/src/validation/validator.dart @@ -1,6 +1,9 @@ import "package:gql/ast.dart" as ast; +import "package:gql/src/validation/rules/executable_definitions.dart"; import "package:gql/src/validation/rules/lone_schema_definition.dart"; import "package:gql/src/validation/rules/missing_fragment_definitions.dart"; +import "package:gql/src/validation/rules/possible_type_extensions.dart"; +import "package:gql/src/validation/rules/unique_argument_definition_names.dart"; import "package:gql/src/validation/rules/unique_argument_names.dart"; import "package:gql/src/validation/rules/unique_directive_names.dart"; import "package:gql/src/validation/rules/unique_enum_value_names.dart"; @@ -86,6 +89,9 @@ abstract class ValidationError { this.message, this.node, }); + + @override + String toString() => message ?? super.toString(); } /// Available validation rules @@ -98,7 +104,10 @@ enum ValidationRule { uniqueTypeNames, uniqueInputFieldNames, uniqueArgumentNames, - missingFragmentDefinition + missingFragmentDefinition, + possibleTypeExtensions, + uniqueArgumentDefinitionNames, + executableDefinitions, } ValidatingVisitor? _mapRule(ValidationRule rule) { @@ -121,8 +130,12 @@ ValidatingVisitor? _mapRule(ValidationRule rule) { return UniqueArgumentNames(); case ValidationRule.missingFragmentDefinition: return const MissingFragmentDefinition(); - default: - return null; + case ValidationRule.possibleTypeExtensions: + return PossibleTypeExtensions(); + case ValidationRule.uniqueArgumentDefinitionNames: + return UniqueArgumentDefinitionNames(); + case ValidationRule.executableDefinitions: + return ExecutableDefinitions(); } } diff --git a/gql/test/validation/executable_definitions_test.dart b/gql/test/validation/executable_definitions_test.dart new file mode 100644 index 000000000..0e78ab212 --- /dev/null +++ b/gql/test/validation/executable_definitions_test.dart @@ -0,0 +1,96 @@ +import "package:gql/src/validation/validator.dart"; +import "package:test/test.dart"; + +import "./common.dart"; + +final validate = createValidator({ + ValidationRule.executableDefinitions, +}); + +void main() { + group("Executable definitions", () { + test("with only operation", () { + expect( + validate( + """ + query Foo { + dog { + name + } + } + """, + ), + isEmpty, + ); + }); + + test("with operation and fragment", () { + expect( + validate( + """ + query Foo { + dog { + name + ...Frag + } + } + + fragment Frag on Dog { + name + } + """, + ), + isEmpty, + ); + }); + + test("with type definition", () { + expect( + validate(""" + query Foo { + dog { + name + } + } + + type Cow { + name: String + } + + extend type Dog { + color: String + } + """).map((it) => it.toString()).toList(), + equals( + [ + 'The "Cow" definition is not executable.', + 'The "Dog" definition is not executable.', + ], + ), + ); + }); + + test("with schema definition", () { + expect( + validate(""" + schema { + query: Query + } + + type Query { + test: String + } + + extend schema @directive + """).map((it) => it.toString()).toList(), + equals( + [ + "The schema definition is not executable.", + 'The "Query" definition is not executable.', + "The schema definition is not executable.", + ], + ), + ); + }); + }); +} diff --git a/gql/test/validation/possible_type_extensions_test.dart b/gql/test/validation/possible_type_extensions_test.dart new file mode 100644 index 000000000..46081b43d --- /dev/null +++ b/gql/test/validation/possible_type_extensions_test.dart @@ -0,0 +1,147 @@ +import "package:gql/src/validation/validator.dart"; +import "package:test/test.dart"; + +import "./common.dart"; + +final validate = createValidator({ + ValidationRule.possibleTypeExtensions, +}); + +void main() { + group("Possible type extensions", () { + test("one extension per type", () { + expect( + validate( + """ + scalar FooScalar + type FooObject + interface FooInterface + union FooUnion + enum FooEnum + input FooInputObject + + extend scalar FooScalar @dummy + extend type FooObject @dummy + extend interface FooInterface @dummy + extend union FooUnion @dummy + extend enum FooEnum @dummy + extend input FooInputObject @dummy + """, + ).map((it) => it.toString()), + equals([]), + ); + }); + + test("multiple extension per type", () { + expect( + validate( + """ + scalar FooScalar + type FooObject + interface FooInterface + union FooUnion + enum FooEnum + input FooInputObject + + extend scalar FooScalar @dummy + extend type FooObject @dummy + extend interface FooInterface @dummy + extend union FooUnion @dummy + extend enum FooEnum @dummy + extend input FooInputObject @dummy + + extend scalar FooScalar @dummy + extend type FooObject @dummy + extend interface FooInterface @dummy + extend union FooUnion @dummy + extend enum FooEnum @dummy + extend input FooInputObject @dummy + """, + ).map((it) => it.toString()), + equals([]), + ); + }); + + test("ignores non type definitions", () { + expect( + validate( + """ + query Foo { __typename } + fragment Foo on Query { __typename } + directive @Foo on SCHEMA + + extend scalar Foo @dummy + extend type Foo @dummy + extend interface Foo @dummy + extend union Foo @dummy + extend enum Foo @dummy + extend input Foo @dummy + """, + ).map((it) => it.toString()), + equals([ + 'Cannot extend type "Foo" because it is not defined.', + 'Cannot extend type "Foo" because it is not defined.', + 'Cannot extend type "Foo" because it is not defined.', + 'Cannot extend type "Foo" because it is not defined.', + 'Cannot extend type "Foo" because it is not defined.', + 'Cannot extend type "Foo" because it is not defined.', + ]), + ); + }); + + test("undefined type", () { + expect( + validate( + """ + type Known + + extend scalar Unknown @dummy + extend type Unknown @dummy + extend interface Unknown @dummy + extend union Unknown @dummy + extend enum Unknown @dummy + extend input Unknown @dummy + """, + ).map((it) => it.toString()), + equals([ + 'Cannot extend type "Unknown" because it is not defined.', + 'Cannot extend type "Unknown" because it is not defined.', + 'Cannot extend type "Unknown" because it is not defined.', + 'Cannot extend type "Unknown" because it is not defined.', + 'Cannot extend type "Unknown" because it is not defined.', + 'Cannot extend type "Unknown" because it is not defined.', + ]), + ); + }); + + test("incorrect types", () { + expect( + validate( + """ + scalar FooScalar + type FooObject + interface FooInterface + union FooUnion + enum FooEnum + input FooInputObject + + extend type FooScalar @dummy + extend interface FooObject @dummy + extend union FooInterface @dummy + extend enum FooUnion @dummy + extend input FooEnum @dummy + extend scalar FooInputObject @dummy + """, + ).map((it) => it.toString()), + equals([ + 'Cannot extend non-object type "FooScalar".', + 'Cannot extend non-interface type "FooObject".', + 'Cannot extend non-union type "FooInterface".', + 'Cannot extend non-enum type "FooUnion".', + 'Cannot extend non-input type "FooEnum".', + 'Cannot extend non-scalar type "FooInputObject".', + ]), + ); + }); + }); +} diff --git a/gql/test/validation/unique_argument_defintion_names_test.dart b/gql/test/validation/unique_argument_defintion_names_test.dart new file mode 100644 index 000000000..a84a423d4 --- /dev/null +++ b/gql/test/validation/unique_argument_defintion_names_test.dart @@ -0,0 +1,153 @@ +import "package:gql/src/validation/validator.dart"; +import "package:test/test.dart"; + +import "./common.dart"; + +final validate = createValidator({ + ValidationRule.uniqueArgumentDefinitionNames, +}); + +void main() { + group("Unique argument definition names", () { + test("no arguments", () { + expect( + validate( + """ + type SomeObject { + someField: String + } + + interface SomeInterface { + someField: String + } + + directive @someDirective on QUERY + """, + ).map((it) => it.toString()), + equals([]), + ); + }); + + test("one argument", () { + expect( + validate( + """ + type SomeObject { + someField(foo: String): String + } + + interface SomeInterface { + someField(foo: String): String + } + + extend type SomeObject { + anotherField(foo: String): String + } + + extend interface SomeInterface { + anotherField(foo: String): String + } + + directive @someDirective(foo: String) on QUERY + """, + ).map((it) => it.toString()), + equals([]), + ); + }); + + test("multiple arguments", () { + expect( + validate( + """ + type SomeObject { + someField( + foo: String + bar: String + ): String + } + + interface SomeInterface { + someField( + foo: String + bar: String + ): String + } + + extend type SomeObject { + anotherField( + foo: String + bar: String + ): String + } + + extend interface SomeInterface { + anotherField( + foo: String + bar: String + ): String + } + + directive @someDirective( + foo: String + bar: String + ) on QUERY + """, + ).map((it) => it.toString()), + equals([]), + ); + }); + + test("duplicating arguments", () { + expect( + validate( + """ + type SomeObject { + someField( + foo: String + bar: String + foo: String + ): String + } + + interface SomeInterface { + someField( + foo: String + bar: String + foo: String + ): String + } + + extend type SomeObject { + anotherField( + foo: String + bar: String + bar: String + ): String + } + + extend interface SomeInterface { + anotherField( + bar: String + foo: String + foo: String + ): String + } + + directive @someDirective( + foo: String + bar: String + foo: String + ) on QUERY + """, + ).map((it) => it.toString()), + equals([ + 'Argument "SomeObject.someField(foo:)" can only be defined once.', + 'Argument "SomeInterface.someField(foo:)" can only be defined once.', + 'Argument "SomeObject.anotherField(bar:)" can only be defined once.', + 'Argument "SomeInterface.anotherField(foo:)" can only be defined once.', + 'Argument "@someDirective(foo:)" can only be defined once.', + ]), + ); + }); + }); +}