diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ed63e81..d8630ea6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ Changes in this section are not yet released. If you need access to these changes before we cut a release, check out our `@main` NPM releases. Each commit on the main branch is [published to NPM](https://www.npmjs.com/package/grats?activeTab=versions) under the `main` tag. +- **Breaking** + - GraphQL types and interfaces defined with TypeScript classes or interfaces will now inherit fields/interfaces from their inheritance/implementation chains. This means that if you define a GraphQL field on a parent class/interface, it will be inherited by the child class/interface. Previously each type/interface needed to independently mark the field as a `@gqlField`. (#145) +- **Features** + - TypeScript classes (and abstract classes) can now be used to define GraphQL interfaces. (#145) + ## 0.0.28 Version `0.0.28` comes with a number of new features and should not have any breaking changes relative to `0.0.27`. The new features: diff --git a/src/Errors.ts b/src/Errors.ts index 94610a8e..217f344b 100644 --- a/src/Errors.ts +++ b/src/Errors.ts @@ -63,7 +63,11 @@ export function invalidScalarTagUsage() { } export function invalidInterfaceTagUsage() { - return `\`@${INTERFACE_TAG}\` can only be used on interface declarations. e.g. \`interface MyInterface {}\``; + return `\`@${INTERFACE_TAG}\` can only be used on interface or abstract class declarations. e.g. \`interface MyInterface {}\` or \`abstract class MyInterface {}\``; +} + +export function interfaceClassNotAbstract() { + return `Expected \`@${INTERFACE_TAG}\` class to be abstract. \`@${INTERFACE_TAG}\` can only be used on interface or abstract class declarations. e.g. \`interface MyInterface {}\` or \`abstract class MyInterface {}\``; } export function invalidEnumTagUsage() { diff --git a/src/Extractor.ts b/src/Extractor.ts index 0fd19729..982cb6a1 100644 --- a/src/Extractor.ts +++ b/src/Extractor.ts @@ -319,7 +319,9 @@ class Extractor { } extractInterface(node: ts.Node, tag: ts.JSDocTag) { - if (ts.isInterfaceDeclaration(node)) { + if (ts.isClassDeclaration(node)) { + this.interfaceClassDeclaration(node, tag); + } else if (ts.isInterfaceDeclaration(node)) { this.interfaceInterfaceDeclaration(node, tag); } else { this.report(tag, E.invalidInterfaceTagUsage()); @@ -1313,6 +1315,46 @@ class Extractor { ); } + interfaceClassDeclaration(node: ts.ClassDeclaration, tag: ts.JSDocTag) { + const isAbstract = node.modifiers?.some((modifier) => { + return modifier.kind === ts.SyntaxKind.AbstractKeyword; + }); + if (!isAbstract) { + return this.report(node, E.interfaceClassNotAbstract()); + } + if (node.name == null) { + return this.report(node, E.typeTagOnUnnamedClass()); + } + + const name = this.entityName(node, tag); + if (name == null || name.value == null) { + return; + } + + const description = this.collectDescription(node); + + const fieldMembers = node.members.filter((member) => { + // Static methods are handled when we encounter the tag at our top-level + // traversal, similar to how functions are handled. We filter them out here to ensure + // we don't double-visit them. + return !isStaticMethod(member); + }); + + const fields = this.collectFields(fieldMembers); + const interfaces = this.collectInterfaces(node); + this.recordTypeName(node, name, "INTERFACE"); + + this.definitions.push( + this.gql.interfaceTypeDefinition( + node, + name, + fields, + interfaces, + description, + ), + ); + } + collectFields( members: ReadonlyArray, ): Array { diff --git a/src/InterfaceGraph.ts b/src/InterfaceGraph.ts deleted file mode 100644 index b1710fa0..00000000 --- a/src/InterfaceGraph.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { TypeContext } from "./TypeContext"; -import { DefaultMap } from "./utils/helpers"; -import { DefinitionNode, Kind } from "graphql"; - -export type InterfaceImplementor = { kind: "TYPE" | "INTERFACE"; name: string }; -export type InterfaceMap = DefaultMap>; - -/** - * Compute a map of interfaces to the types and interfaces that implement them. - */ -export function computeInterfaceMap( - typeContext: TypeContext, - docs: DefinitionNode[], -): InterfaceMap { - // For each interface definition, we need to know which types and interfaces implement it. - const graph = new DefaultMap>( - () => new Set(), - ); - - const add = (interfaceName: string, implementor: InterfaceImplementor) => { - graph.get(interfaceName).add(implementor); - }; - - for (const doc of docs) { - switch (doc.kind) { - case Kind.INTERFACE_TYPE_DEFINITION: - case Kind.INTERFACE_TYPE_EXTENSION: - for (const implementor of doc.interfaces ?? []) { - const resolved = typeContext.resolveUnresolvedNamedType( - implementor.name, - ); - if (resolved.kind === "ERROR") { - // We trust that these errors will be reported elsewhere. - continue; - } - add(resolved.value.value, { - kind: "INTERFACE", - name: doc.name.value, - }); - } - break; - case Kind.OBJECT_TYPE_DEFINITION: - case Kind.OBJECT_TYPE_EXTENSION: - for (const implementor of doc.interfaces ?? []) { - const resolved = typeContext.resolveUnresolvedNamedType( - implementor.name, - ); - if (resolved.kind === "ERROR") { - // We trust that these errors will be reported elsewhere. - continue; - } - add(resolved.value.value, { kind: "TYPE", name: doc.name.value }); - } - break; - } - } - - return graph; -} diff --git a/src/TypeContext.ts b/src/TypeContext.ts index f8b40242..0c1101fc 100644 --- a/src/TypeContext.ts +++ b/src/TypeContext.ts @@ -249,4 +249,59 @@ export class TypeContext { } return entityName; } + + // Given the name of a class or interface, return all the parent classes and + // interfaces. + getAllParentClassesForName(name: ts.Identifier): Set { + const symbol = this.checker.getSymbolAtLocation(name); + if (symbol == null) { + return new Set(); + } + return this.getAllParentClasses(symbol); + } + + /* + * Walk the inheritance chain and collect all the parent classes. + */ + getAllParentClasses( + symbol: ts.Symbol, + parents: Set = new Set(), + ): Set { + if (symbol.declarations == null) { + return parents; + } + + for (const declaration of symbol.declarations) { + const extendsClauses = getClassHeritageClauses(declaration); + if (extendsClauses == null) { + continue; + } + for (const heritageClause of extendsClauses) { + for (const type of heritageClause.types) { + const typeSymbol = this.checker.getSymbolAtLocation(type.expression); + if (typeSymbol == null || typeSymbol.declarations == null) { + continue; + } + for (const decl of typeSymbol.declarations) { + const name = this._declarationToName.get(decl); + if (name != null) { + parents.add(name); + } + } + // Recurse to find the parents of the parent. + this.getAllParentClasses(typeSymbol, parents); + } + } + } + return parents; + } +} + +function getClassHeritageClauses( + declaration: ts.Declaration, +): ts.NodeArray | null { + if (ts.isClassDeclaration(declaration)) { + return declaration.heritageClauses ?? null; + } + return null; } diff --git a/src/lib.ts b/src/lib.ts index f58defe2..65f819f6 100644 --- a/src/lib.ts +++ b/src/lib.ts @@ -32,6 +32,7 @@ import { validateDuplicateContextOrInfo } from "./validations/validateDuplicateC import { validateSemanticNullability } from "./validations/validateSemanticNullability"; import { resolveTypes } from "./transforms/resolveTypes"; import { resolveResolverParams } from "./transforms/resolveResolverParams"; +import { propagateHeritage } from "./transforms/propagateHeritage"; // Export the TypeScript plugin implementation used by // grats-ts-plugin @@ -115,6 +116,8 @@ export function extractSchemaAndDoc( .andThen((doc) => applyDefaultNullability(doc, config)) // Merge any `extend` definitions into their base definitions. .map((doc) => mergeExtensions(doc)) + // Add fields from extended classes and implemented interfaces. + .map((doc) => propagateHeritage(ctx, doc)) // Sort the definitions in the document to ensure a stable output. .map((doc) => sortSchemaAst(doc)) .result(); diff --git a/src/tests/fixtures/extend_interface/addStringFieldToInterface.ts.expected b/src/tests/fixtures/extend_interface/addStringFieldToInterface.ts.expected index f264e712..96b9105f 100644 --- a/src/tests/fixtures/extend_interface/addStringFieldToInterface.ts.expected +++ b/src/tests/fixtures/extend_interface/addStringFieldToInterface.ts.expected @@ -34,7 +34,7 @@ OUTPUT ----------------- -- SDL -- interface IPerson { - greeting: String + greeting: String @metadata(exportName: "greeting", tsModulePath: "grats/src/tests/fixtures/extend_interface/addStringFieldToInterface.ts") hello: String @metadata } diff --git a/src/tests/fixtures/extend_interface/addStringFieldToInterfaceImplementedByInterface.ts.expected b/src/tests/fixtures/extend_interface/addStringFieldToInterfaceImplementedByInterface.ts.expected index 0665b11e..3b05dacf 100644 --- a/src/tests/fixtures/extend_interface/addStringFieldToInterfaceImplementedByInterface.ts.expected +++ b/src/tests/fixtures/extend_interface/addStringFieldToInterfaceImplementedByInterface.ts.expected @@ -37,87 +37,32 @@ class Admin implements IPerson, IThing { ----------------- OUTPUT ----------------- --- SDL -- -interface IPerson implements IThing { - greeting: String -} +src/tests/fixtures/extend_interface/addStringFieldToInterfaceImplementedByInterface.ts:15:1 - error: Type IPerson must define one or more fields. -interface IThing { - greeting: String -} +15 interface IPerson extends IThing { + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +16 name: string; + ~~~~~~~~~~~~~~~ +17 // Should have greeting added + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +18 } + ~ +src/tests/fixtures/extend_interface/addStringFieldToInterfaceImplementedByInterface.ts:2:1 - error: Interface field IThing.greeting expected but IPerson does not provide it. -type Admin implements IPerson & IThing { - greeting: String @metadata(exportName: "greeting", tsModulePath: "grats/src/tests/fixtures/extend_interface/addStringFieldToInterfaceImplementedByInterface.ts") -} +2 export function greeting(thing: IThing): string { + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +3 return `Hello ${thing.name}!`; + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +4 } + ~ -type User implements IPerson & IThing { - greeting: String @metadata(exportName: "greeting", tsModulePath: "grats/src/tests/fixtures/extend_interface/addStringFieldToInterfaceImplementedByInterface.ts") -} --- TypeScript -- -import { greeting as adminGreetingResolver } from "./addStringFieldToInterfaceImplementedByInterface"; -import { greeting as userGreetingResolver } from "./addStringFieldToInterfaceImplementedByInterface"; -import { GraphQLSchema, GraphQLInterfaceType, GraphQLString, GraphQLObjectType } from "graphql"; -export function getSchema(): GraphQLSchema { - const IThingType: GraphQLInterfaceType = new GraphQLInterfaceType({ - name: "IThing", - fields() { - return { - greeting: { - name: "greeting", - type: GraphQLString - } - }; - } - }); - const IPersonType: GraphQLInterfaceType = new GraphQLInterfaceType({ - name: "IPerson", - fields() { - return { - greeting: { - name: "greeting", - type: GraphQLString - } - }; - }, - interfaces() { - return [IThingType]; - } - }); - const AdminType: GraphQLObjectType = new GraphQLObjectType({ - name: "Admin", - fields() { - return { - greeting: { - name: "greeting", - type: GraphQLString, - resolve(source) { - return adminGreetingResolver(source); - } - } - }; - }, - interfaces() { - return [IPersonType, IThingType]; - } - }); - const UserType: GraphQLObjectType = new GraphQLObjectType({ - name: "User", - fields() { - return { - greeting: { - name: "greeting", - type: GraphQLString, - resolve(source) { - return userGreetingResolver(source); - } - } - }; - }, - interfaces() { - return [IPersonType, IThingType]; - } - }); - return new GraphQLSchema({ - types: [IPersonType, IThingType, AdminType, UserType] - }); -} + src/tests/fixtures/extend_interface/addStringFieldToInterfaceImplementedByInterface.ts:15:1 + 15 interface IPerson extends IThing { + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + 16 name: string; + ~~~~~~~~~~~~~~~ + 17 // Should have greeting added + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + 18 } + ~ + Related location diff --git a/src/tests/fixtures/extend_interface/addStringFieldToInterfaceTwice.invalid.ts.expected b/src/tests/fixtures/extend_interface/addStringFieldToInterfaceTwice.invalid.ts.expected index 3b898e1a..1c2e6843 100644 --- a/src/tests/fixtures/extend_interface/addStringFieldToInterfaceTwice.invalid.ts.expected +++ b/src/tests/fixtures/extend_interface/addStringFieldToInterfaceTwice.invalid.ts.expected @@ -39,24 +39,6 @@ OUTPUT ----------------- src/tests/fixtures/extend_interface/addStringFieldToInterfaceTwice.invalid.ts:9:17 - error: Field "IPerson.greeting" can only be defined once. -9 export function greeting(person: IPerson): string { - ~~~~~~~~ - - src/tests/fixtures/extend_interface/addStringFieldToInterfaceTwice.invalid.ts:13:5 - 13 /** @gqlField greeting */ - ~~~~~~~~~~~~~~~~~~~ - Related location -src/tests/fixtures/extend_interface/addStringFieldToInterfaceTwice.invalid.ts:9:17 - error: Field "Admin.greeting" can only be defined once. - -9 export function greeting(person: IPerson): string { - ~~~~~~~~ - - src/tests/fixtures/extend_interface/addStringFieldToInterfaceTwice.invalid.ts:13:5 - 13 /** @gqlField greeting */ - ~~~~~~~~~~~~~~~~~~~ - Related location -src/tests/fixtures/extend_interface/addStringFieldToInterfaceTwice.invalid.ts:9:17 - error: Field "User.greeting" can only be defined once. - 9 export function greeting(person: IPerson): string { ~~~~~~~~ diff --git a/src/tests/fixtures/extend_interface/redefineFiledThatExistsOnConcreteType.invalid.ts b/src/tests/fixtures/extend_interface/redefineFieldThatExistsOnConcreteType.ts similarity index 76% rename from src/tests/fixtures/extend_interface/redefineFiledThatExistsOnConcreteType.invalid.ts rename to src/tests/fixtures/extend_interface/redefineFieldThatExistsOnConcreteType.ts index 9c4c93f8..7788905c 100644 --- a/src/tests/fixtures/extend_interface/redefineFiledThatExistsOnConcreteType.invalid.ts +++ b/src/tests/fixtures/extend_interface/redefineFieldThatExistsOnConcreteType.ts @@ -5,7 +5,9 @@ interface IPerson { hello: string; } -/** @gqlField */ +/** + * As defined on the interface + * @gqlField */ export function greeting(person: IPerson): string { return `Hello ${person.name}!`; } @@ -17,7 +19,9 @@ class User implements IPerson { /** @gqlField */ hello: string; - /** @gqlField */ + /** + * As defined on the concrete type + * @gqlField */ greeting(): string { return `Hello ${this.name}!`; } diff --git a/src/tests/fixtures/extend_interface/redefineFieldThatExistsOnConcreteType.ts.expected b/src/tests/fixtures/extend_interface/redefineFieldThatExistsOnConcreteType.ts.expected new file mode 100644 index 00000000..e72cd1d0 --- /dev/null +++ b/src/tests/fixtures/extend_interface/redefineFieldThatExistsOnConcreteType.ts.expected @@ -0,0 +1,89 @@ +----------------- +INPUT +----------------- +/** @gqlInterface */ +interface IPerson { + name: string; + /** @gqlField */ + hello: string; +} + +/** + * As defined on the interface + * @gqlField */ +export function greeting(person: IPerson): string { + return `Hello ${person.name}!`; +} + +/** @gqlType */ +class User implements IPerson { + __typename: "User"; + name: string; + /** @gqlField */ + hello: string; + + /** + * As defined on the concrete type + * @gqlField */ + greeting(): string { + return `Hello ${this.name}!`; + } +} + +----------------- +OUTPUT +----------------- +-- SDL -- +interface IPerson { + """As defined on the interface""" + greeting: String @metadata(exportName: "greeting", tsModulePath: "grats/src/tests/fixtures/extend_interface/redefineFieldThatExistsOnConcreteType.ts") + hello: String @metadata +} + +type User implements IPerson { + """As defined on the concrete type""" + greeting: String @metadata + hello: String @metadata +} +-- TypeScript -- +import { GraphQLSchema, GraphQLInterfaceType, GraphQLString, GraphQLObjectType } from "graphql"; +export function getSchema(): GraphQLSchema { + const IPersonType: GraphQLInterfaceType = new GraphQLInterfaceType({ + name: "IPerson", + fields() { + return { + greeting: { + description: "As defined on the interface", + name: "greeting", + type: GraphQLString + }, + hello: { + name: "hello", + type: GraphQLString + } + }; + } + }); + const UserType: GraphQLObjectType = new GraphQLObjectType({ + name: "User", + fields() { + return { + greeting: { + description: "As defined on the concrete type", + name: "greeting", + type: GraphQLString + }, + hello: { + name: "hello", + type: GraphQLString + } + }; + }, + interfaces() { + return [IPersonType]; + } + }); + return new GraphQLSchema({ + types: [IPersonType, UserType] + }); +} diff --git a/src/tests/fixtures/extend_interface/redefineFieldThatExistsOnConcreteTypeWithIncompatibleSubtype.ts b/src/tests/fixtures/extend_interface/redefineFieldThatExistsOnConcreteTypeWithIncompatibleSubtype.ts new file mode 100644 index 00000000..8efce361 --- /dev/null +++ b/src/tests/fixtures/extend_interface/redefineFieldThatExistsOnConcreteTypeWithIncompatibleSubtype.ts @@ -0,0 +1,29 @@ +/** @gqlInterface */ +interface IPerson { + name: string; + /** @gqlField */ + hello: string; +} + +/** + * As defined on the interface + * @killsParentOnException + * @gqlField */ +export function greeting(person: IPerson): string { + return `Hello ${person.name}!`; +} + +/** @gqlType */ +class User implements IPerson { + __typename: "User"; + name: string; + /** @gqlField */ + hello: string; + + /** + * As defined on the concrete type + * @gqlField */ + greeting(): string { + return `Hello ${this.name}!`; + } +} diff --git a/src/tests/fixtures/extend_interface/redefineFieldThatExistsOnConcreteTypeWithIncompatibleSubtype.ts.expected b/src/tests/fixtures/extend_interface/redefineFieldThatExistsOnConcreteTypeWithIncompatibleSubtype.ts.expected new file mode 100644 index 00000000..c55bf0fd --- /dev/null +++ b/src/tests/fixtures/extend_interface/redefineFieldThatExistsOnConcreteTypeWithIncompatibleSubtype.ts.expected @@ -0,0 +1,45 @@ +----------------- +INPUT +----------------- +/** @gqlInterface */ +interface IPerson { + name: string; + /** @gqlField */ + hello: string; +} + +/** + * As defined on the interface + * @killsParentOnException + * @gqlField */ +export function greeting(person: IPerson): string { + return `Hello ${person.name}!`; +} + +/** @gqlType */ +class User implements IPerson { + __typename: "User"; + name: string; + /** @gqlField */ + hello: string; + + /** + * As defined on the concrete type + * @gqlField */ + greeting(): string { + return `Hello ${this.name}!`; + } +} + +----------------- +OUTPUT +----------------- +src/tests/fixtures/extend_interface/redefineFieldThatExistsOnConcreteTypeWithIncompatibleSubtype.ts:10:5 - error: Interface field IPerson.greeting expects type String! but User.greeting is type String. + +10 * @killsParentOnException + ~~~~~~~~~~~~~~~~~~~~~~ + + src/tests/fixtures/extend_interface/redefineFieldThatExistsOnConcreteTypeWithIncompatibleSubtype.ts:26:15 + 26 greeting(): string { + ~~~~~~ + Related location diff --git a/src/tests/fixtures/extend_interface/redefineFiledThatExistsOnConcreteType.invalid.ts.expected b/src/tests/fixtures/extend_interface/redefineFiledThatExistsOnConcreteType.invalid.ts.expected deleted file mode 100644 index ec598487..00000000 --- a/src/tests/fixtures/extend_interface/redefineFiledThatExistsOnConcreteType.invalid.ts.expected +++ /dev/null @@ -1,40 +0,0 @@ ------------------ -INPUT ------------------ -/** @gqlInterface */ -interface IPerson { - name: string; - /** @gqlField */ - hello: string; -} - -/** @gqlField */ -export function greeting(person: IPerson): string { - return `Hello ${person.name}!`; -} - -/** @gqlType */ -class User implements IPerson { - __typename: "User"; - name: string; - /** @gqlField */ - hello: string; - - /** @gqlField */ - greeting(): string { - return `Hello ${this.name}!`; - } -} - ------------------ -OUTPUT ------------------ -src/tests/fixtures/extend_interface/redefineFiledThatExistsOnConcreteType.invalid.ts:21:3 - error: Field "User.greeting" can only be defined once. - -21 greeting(): string { - ~~~~~~~~ - - src/tests/fixtures/extend_interface/redefineFiledThatExistsOnConcreteType.invalid.ts:9:17 - 9 export function greeting(person: IPerson): string { - ~~~~~~~~ - Related location diff --git a/src/tests/fixtures/extend_interface/redefineFiledThatExistsOnImplementingInterface.invalid.ts.expected b/src/tests/fixtures/extend_interface/redefineFiledThatExistsOnImplementingInterface.invalid.ts.expected deleted file mode 100644 index 21db8a89..00000000 --- a/src/tests/fixtures/extend_interface/redefineFiledThatExistsOnImplementingInterface.invalid.ts.expected +++ /dev/null @@ -1,38 +0,0 @@ ------------------ -INPUT ------------------ -/** @gqlInterface */ -interface IPerson { - name: string; - /** @gqlField */ - hello: string; -} - -/** @gqlField */ -export function greeting(person: IPerson): string { - return `Hello ${person.name}!`; -} - -/** @gqlInterface */ -interface User extends IPerson { - __typename: "User"; - name: string; - /** @gqlField */ - hello: string; - - /** @gqlField */ - greeting(): string; -} - ------------------ -OUTPUT ------------------ -src/tests/fixtures/extend_interface/redefineFiledThatExistsOnImplementingInterface.invalid.ts:21:3 - error: Field "User.greeting" can only be defined once. - -21 greeting(): string; - ~~~~~~~~ - - src/tests/fixtures/extend_interface/redefineFiledThatExistsOnImplementingInterface.invalid.ts:9:17 - 9 export function greeting(person: IPerson): string { - ~~~~~~~~ - Related location diff --git a/src/tests/fixtures/extend_interface/redefineFiledThatExistsOnImplementingInterface.invalid.ts b/src/tests/fixtures/extend_interface/redefineFiledThatExistsOnImplementingInterface.ts similarity index 100% rename from src/tests/fixtures/extend_interface/redefineFiledThatExistsOnImplementingInterface.invalid.ts rename to src/tests/fixtures/extend_interface/redefineFiledThatExistsOnImplementingInterface.ts diff --git a/src/tests/fixtures/extend_interface/redefineFiledThatExistsOnImplementingInterface.ts.expected b/src/tests/fixtures/extend_interface/redefineFiledThatExistsOnImplementingInterface.ts.expected new file mode 100644 index 00000000..28f12252 --- /dev/null +++ b/src/tests/fixtures/extend_interface/redefineFiledThatExistsOnImplementingInterface.ts.expected @@ -0,0 +1,79 @@ +----------------- +INPUT +----------------- +/** @gqlInterface */ +interface IPerson { + name: string; + /** @gqlField */ + hello: string; +} + +/** @gqlField */ +export function greeting(person: IPerson): string { + return `Hello ${person.name}!`; +} + +/** @gqlInterface */ +interface User extends IPerson { + __typename: "User"; + name: string; + /** @gqlField */ + hello: string; + + /** @gqlField */ + greeting(): string; +} + +----------------- +OUTPUT +----------------- +-- SDL -- +interface IPerson { + greeting: String @metadata(exportName: "greeting", tsModulePath: "grats/src/tests/fixtures/extend_interface/redefineFiledThatExistsOnImplementingInterface.ts") + hello: String @metadata +} + +interface User implements IPerson { + greeting: String @metadata + hello: String @metadata +} +-- TypeScript -- +import { GraphQLSchema, GraphQLInterfaceType, GraphQLString } from "graphql"; +export function getSchema(): GraphQLSchema { + const IPersonType: GraphQLInterfaceType = new GraphQLInterfaceType({ + name: "IPerson", + fields() { + return { + greeting: { + name: "greeting", + type: GraphQLString + }, + hello: { + name: "hello", + type: GraphQLString + } + }; + } + }); + const UserType: GraphQLInterfaceType = new GraphQLInterfaceType({ + name: "User", + fields() { + return { + greeting: { + name: "greeting", + type: GraphQLString + }, + hello: { + name: "hello", + type: GraphQLString + } + }; + }, + interfaces() { + return [IPersonType]; + } + }); + return new GraphQLSchema({ + types: [IPersonType, UserType] + }); +} diff --git a/src/tests/fixtures/extend_type/interfaceFirstArgumentType.ts.expected b/src/tests/fixtures/extend_type/interfaceFirstArgumentType.ts.expected index 4a357890..e96965bc 100644 --- a/src/tests/fixtures/extend_type/interfaceFirstArgumentType.ts.expected +++ b/src/tests/fixtures/extend_type/interfaceFirstArgumentType.ts.expected @@ -24,7 +24,7 @@ OUTPUT -- SDL -- interface IFoo { bar: String @metadata - greeting: String + greeting: String @metadata(exportName: "greeting", tsModulePath: "grats/src/tests/fixtures/extend_type/interfaceFirstArgumentType.ts") } type SomeType { diff --git a/src/tests/fixtures/field_definitions/FieldAsStaticClassMethodOnClassInterface.ts b/src/tests/fixtures/field_definitions/FieldAsStaticClassMethodOnClassInterface.ts new file mode 100644 index 00000000..0fc6de72 --- /dev/null +++ b/src/tests/fixtures/field_definitions/FieldAsStaticClassMethodOnClassInterface.ts @@ -0,0 +1,13 @@ +/** @gqlInterface */ +export class User { + /** @gqlField */ + name: string; + + /** @gqlField */ + static getUser(_: Query): User { + return new User(); + } +} + +/** @gqlType */ +type Query = unknown; diff --git a/src/tests/fixtures/field_definitions/FieldAsStaticClassMethodOnClassInterface.ts.expected b/src/tests/fixtures/field_definitions/FieldAsStaticClassMethodOnClassInterface.ts.expected new file mode 100644 index 00000000..1b0df47e --- /dev/null +++ b/src/tests/fixtures/field_definitions/FieldAsStaticClassMethodOnClassInterface.ts.expected @@ -0,0 +1,31 @@ +----------------- +INPUT +----------------- +/** @gqlInterface */ +export class User { + /** @gqlField */ + name: string; + + /** @gqlField */ + static getUser(_: Query): User { + return new User(); + } +} + +/** @gqlType */ +type Query = unknown; + +----------------- +OUTPUT +----------------- +src/tests/fixtures/field_definitions/FieldAsStaticClassMethodOnClassInterface.ts:2:1 - error: Expected `@gqlInterface` class to be abstract. `@gqlInterface` can only be used on interface or abstract class declarations. e.g. `interface MyInterface {}` or `abstract class MyInterface {}` + + 2 export class User { + ~~~~~~~~~~~~~~~~~~~ + 3 /** @gqlField */ + ~~~~~~~~~~~~~~~~~~ +... + 9 } + ~~~ + 10 } + ~ diff --git a/src/tests/fixtures/inheritance/classInheritsFieldFromAbstractParent.ts b/src/tests/fixtures/inheritance/classInheritsFieldFromAbstractParent.ts new file mode 100644 index 00000000..f4cefa86 --- /dev/null +++ b/src/tests/fixtures/inheritance/classInheritsFieldFromAbstractParent.ts @@ -0,0 +1,11 @@ +/** @gqlType */ +abstract class Parent { + /** @gqlField */ + parentField: string; +} + +/** @gqlType */ +export class Child extends Parent { + /** @gqlField */ + childField: string; +} diff --git a/src/tests/fixtures/inheritance/classInheritsFieldFromAbstractParent.ts.expected b/src/tests/fixtures/inheritance/classInheritsFieldFromAbstractParent.ts.expected new file mode 100644 index 00000000..ca337914 --- /dev/null +++ b/src/tests/fixtures/inheritance/classInheritsFieldFromAbstractParent.ts.expected @@ -0,0 +1,60 @@ +----------------- +INPUT +----------------- +/** @gqlType */ +abstract class Parent { + /** @gqlField */ + parentField: string; +} + +/** @gqlType */ +export class Child extends Parent { + /** @gqlField */ + childField: string; +} + +----------------- +OUTPUT +----------------- +-- SDL -- +type Child { + childField: String @metadata + parentField: String @metadata +} + +type Parent { + parentField: String @metadata +} +-- TypeScript -- +import { GraphQLSchema, GraphQLObjectType, GraphQLString } from "graphql"; +export function getSchema(): GraphQLSchema { + const ChildType: GraphQLObjectType = new GraphQLObjectType({ + name: "Child", + fields() { + return { + childField: { + name: "childField", + type: GraphQLString + }, + parentField: { + name: "parentField", + type: GraphQLString + } + }; + } + }); + const ParentType: GraphQLObjectType = new GraphQLObjectType({ + name: "Parent", + fields() { + return { + parentField: { + name: "parentField", + type: GraphQLString + } + }; + } + }); + return new GraphQLSchema({ + types: [ChildType, ParentType] + }); +} diff --git a/src/tests/fixtures/inheritance/classInheritsFieldFromInterface.ts b/src/tests/fixtures/inheritance/classInheritsFieldFromInterface.ts new file mode 100644 index 00000000..2537f8cc --- /dev/null +++ b/src/tests/fixtures/inheritance/classInheritsFieldFromInterface.ts @@ -0,0 +1,13 @@ +/** @gqlInterface */ +interface MyInterface { + /** @gqlField */ + interfaceField: string; +} + +/** @gqlType */ +export class MyType implements MyInterface { + __typename: "MyType" = "MyType"; + interfaceField: string; + /** @gqlField */ + typeField: string; +} diff --git a/src/tests/fixtures/inheritance/classInheritsFieldFromInterface.ts.expected b/src/tests/fixtures/inheritance/classInheritsFieldFromInterface.ts.expected new file mode 100644 index 00000000..79fcbe0e --- /dev/null +++ b/src/tests/fixtures/inheritance/classInheritsFieldFromInterface.ts.expected @@ -0,0 +1,65 @@ +----------------- +INPUT +----------------- +/** @gqlInterface */ +interface MyInterface { + /** @gqlField */ + interfaceField: string; +} + +/** @gqlType */ +export class MyType implements MyInterface { + __typename: "MyType" = "MyType"; + interfaceField: string; + /** @gqlField */ + typeField: string; +} + +----------------- +OUTPUT +----------------- +-- SDL -- +interface MyInterface { + interfaceField: String @metadata +} + +type MyType implements MyInterface { + interfaceField: String @metadata + typeField: String @metadata +} +-- TypeScript -- +import { GraphQLSchema, GraphQLInterfaceType, GraphQLString, GraphQLObjectType } from "graphql"; +export function getSchema(): GraphQLSchema { + const MyInterfaceType: GraphQLInterfaceType = new GraphQLInterfaceType({ + name: "MyInterface", + fields() { + return { + interfaceField: { + name: "interfaceField", + type: GraphQLString + } + }; + } + }); + const MyTypeType: GraphQLObjectType = new GraphQLObjectType({ + name: "MyType", + fields() { + return { + interfaceField: { + name: "interfaceField", + type: GraphQLString + }, + typeField: { + name: "typeField", + type: GraphQLString + } + }; + }, + interfaces() { + return [MyInterfaceType]; + } + }); + return new GraphQLSchema({ + types: [MyInterfaceType, MyTypeType] + }); +} diff --git a/src/tests/fixtures/inheritance/classInheritsFieldFromParent.ts b/src/tests/fixtures/inheritance/classInheritsFieldFromParent.ts new file mode 100644 index 00000000..e9f694f9 --- /dev/null +++ b/src/tests/fixtures/inheritance/classInheritsFieldFromParent.ts @@ -0,0 +1,11 @@ +/** @gqlType */ +class Parent { + /** @gqlField */ + parentField: string; +} + +/** @gqlType */ +class Child extends Parent { + /** @gqlField */ + childField: string; +} diff --git a/src/tests/fixtures/inheritance/classInheritsFieldFromParent.ts.expected b/src/tests/fixtures/inheritance/classInheritsFieldFromParent.ts.expected new file mode 100644 index 00000000..58ea3b66 --- /dev/null +++ b/src/tests/fixtures/inheritance/classInheritsFieldFromParent.ts.expected @@ -0,0 +1,60 @@ +----------------- +INPUT +----------------- +/** @gqlType */ +class Parent { + /** @gqlField */ + parentField: string; +} + +/** @gqlType */ +class Child extends Parent { + /** @gqlField */ + childField: string; +} + +----------------- +OUTPUT +----------------- +-- SDL -- +type Child { + childField: String @metadata + parentField: String @metadata +} + +type Parent { + parentField: String @metadata +} +-- TypeScript -- +import { GraphQLSchema, GraphQLObjectType, GraphQLString } from "graphql"; +export function getSchema(): GraphQLSchema { + const ChildType: GraphQLObjectType = new GraphQLObjectType({ + name: "Child", + fields() { + return { + childField: { + name: "childField", + type: GraphQLString + }, + parentField: { + name: "parentField", + type: GraphQLString + } + }; + } + }); + const ParentType: GraphQLObjectType = new GraphQLObjectType({ + name: "Parent", + fields() { + return { + parentField: { + name: "parentField", + type: GraphQLString + } + }; + } + }); + return new GraphQLSchema({ + types: [ChildType, ParentType] + }); +} diff --git a/src/tests/fixtures/inheritance/classInheritsFieldFromParentButDefinesItItself.ts b/src/tests/fixtures/inheritance/classInheritsFieldFromParentButDefinesItItself.ts new file mode 100644 index 00000000..6f506a76 --- /dev/null +++ b/src/tests/fixtures/inheritance/classInheritsFieldFromParentButDefinesItItself.ts @@ -0,0 +1,22 @@ +/** @gqlType */ +class Parent { + /** + * The description from the parent class + * @gqlField + * @deprecated + */ + commonField: string; +} + +/** @gqlType */ +export class Child extends Parent { + /** @gqlField */ + childField: string; + + /** + * The description from the child class + * @killsParentOnException + * @gqlField + */ + commonField: string; +} diff --git a/src/tests/fixtures/inheritance/classInheritsFieldFromParentButDefinesItItself.ts.expected b/src/tests/fixtures/inheritance/classInheritsFieldFromParentButDefinesItItself.ts.expected new file mode 100644 index 00000000..5f6591f7 --- /dev/null +++ b/src/tests/fixtures/inheritance/classInheritsFieldFromParentButDefinesItItself.ts.expected @@ -0,0 +1,76 @@ +----------------- +INPUT +----------------- +/** @gqlType */ +class Parent { + /** + * The description from the parent class + * @gqlField + * @deprecated + */ + commonField: string; +} + +/** @gqlType */ +export class Child extends Parent { + /** @gqlField */ + childField: string; + + /** + * The description from the child class + * @killsParentOnException + * @gqlField + */ + commonField: string; +} + +----------------- +OUTPUT +----------------- +-- SDL -- +type Child { + childField: String @metadata + """The description from the child class""" + commonField: String! @killsParentOnException @metadata +} + +type Parent { + """The description from the parent class""" + commonField: String @deprecated @metadata +} +-- TypeScript -- +import { GraphQLSchema, GraphQLObjectType, GraphQLString, GraphQLNonNull } from "graphql"; +export function getSchema(): GraphQLSchema { + const ChildType: GraphQLObjectType = new GraphQLObjectType({ + name: "Child", + fields() { + return { + childField: { + name: "childField", + type: GraphQLString + }, + commonField: { + description: "The description from the child class", + name: "commonField", + type: new GraphQLNonNull(GraphQLString) + } + }; + } + }); + const ParentType: GraphQLObjectType = new GraphQLObjectType({ + name: "Parent", + fields() { + return { + commonField: { + description: "The description from the parent class", + deprecationReason: "No longer supported", + name: "commonField", + type: GraphQLString + } + }; + } + }); + return new GraphQLSchema({ + types: [ChildType, ParentType] + }); +} diff --git a/src/tests/fixtures/inheritance/classInheritsFieldFromParentFunctionField.ts b/src/tests/fixtures/inheritance/classInheritsFieldFromParentFunctionField.ts new file mode 100644 index 00000000..101a8002 --- /dev/null +++ b/src/tests/fixtures/inheritance/classInheritsFieldFromParentFunctionField.ts @@ -0,0 +1,13 @@ +/** @gqlType */ +class Parent {} + +/** @gqlField */ +export function parentField(_: Parent): string { + return "parentField"; +} + +/** @gqlType */ +export class Child extends Parent { + /** @gqlField */ + childField: string; +} diff --git a/src/tests/fixtures/inheritance/classInheritsFieldFromParentFunctionField.ts.expected b/src/tests/fixtures/inheritance/classInheritsFieldFromParentFunctionField.ts.expected new file mode 100644 index 00000000..6a0e8c0d --- /dev/null +++ b/src/tests/fixtures/inheritance/classInheritsFieldFromParentFunctionField.ts.expected @@ -0,0 +1,70 @@ +----------------- +INPUT +----------------- +/** @gqlType */ +class Parent {} + +/** @gqlField */ +export function parentField(_: Parent): string { + return "parentField"; +} + +/** @gqlType */ +export class Child extends Parent { + /** @gqlField */ + childField: string; +} + +----------------- +OUTPUT +----------------- +-- SDL -- +type Child { + childField: String @metadata + parentField: String @metadata(exportName: "parentField", tsModulePath: "grats/src/tests/fixtures/inheritance/classInheritsFieldFromParentFunctionField.ts") +} + +type Parent { + parentField: String @metadata(exportName: "parentField", tsModulePath: "grats/src/tests/fixtures/inheritance/classInheritsFieldFromParentFunctionField.ts") +} +-- TypeScript -- +import { parentField as childParentFieldResolver } from "./classInheritsFieldFromParentFunctionField"; +import { parentField as parentParentFieldResolver } from "./classInheritsFieldFromParentFunctionField"; +import { GraphQLSchema, GraphQLObjectType, GraphQLString } from "graphql"; +export function getSchema(): GraphQLSchema { + const ChildType: GraphQLObjectType = new GraphQLObjectType({ + name: "Child", + fields() { + return { + childField: { + name: "childField", + type: GraphQLString + }, + parentField: { + name: "parentField", + type: GraphQLString, + resolve(source) { + return childParentFieldResolver(source); + } + } + }; + } + }); + const ParentType: GraphQLObjectType = new GraphQLObjectType({ + name: "Parent", + fields() { + return { + parentField: { + name: "parentField", + type: GraphQLString, + resolve(source) { + return parentParentFieldResolver(source); + } + } + }; + } + }); + return new GraphQLSchema({ + types: [ChildType, ParentType] + }); +} diff --git a/src/tests/fixtures/inheritance/classInheritsFieldThroughNonGraphQLIntermediateParent.ts b/src/tests/fixtures/inheritance/classInheritsFieldThroughNonGraphQLIntermediateParent.ts new file mode 100644 index 00000000..dd2e8eab --- /dev/null +++ b/src/tests/fixtures/inheritance/classInheritsFieldThroughNonGraphQLIntermediateParent.ts @@ -0,0 +1,13 @@ +/** @gqlType */ +class Parent { + /** @gqlField */ + parentField: string; +} + +class Intermediate extends Parent {} + +/** @gqlType */ +export class Child extends Intermediate { + /** @gqlField */ + childField: string; +} diff --git a/src/tests/fixtures/inheritance/classInheritsFieldThroughNonGraphQLIntermediateParent.ts.expected b/src/tests/fixtures/inheritance/classInheritsFieldThroughNonGraphQLIntermediateParent.ts.expected new file mode 100644 index 00000000..936842b2 --- /dev/null +++ b/src/tests/fixtures/inheritance/classInheritsFieldThroughNonGraphQLIntermediateParent.ts.expected @@ -0,0 +1,62 @@ +----------------- +INPUT +----------------- +/** @gqlType */ +class Parent { + /** @gqlField */ + parentField: string; +} + +class Intermediate extends Parent {} + +/** @gqlType */ +export class Child extends Intermediate { + /** @gqlField */ + childField: string; +} + +----------------- +OUTPUT +----------------- +-- SDL -- +type Child { + childField: String @metadata + parentField: String @metadata +} + +type Parent { + parentField: String @metadata +} +-- TypeScript -- +import { GraphQLSchema, GraphQLObjectType, GraphQLString } from "graphql"; +export function getSchema(): GraphQLSchema { + const ChildType: GraphQLObjectType = new GraphQLObjectType({ + name: "Child", + fields() { + return { + childField: { + name: "childField", + type: GraphQLString + }, + parentField: { + name: "parentField", + type: GraphQLString + } + }; + } + }); + const ParentType: GraphQLObjectType = new GraphQLObjectType({ + name: "Parent", + fields() { + return { + parentField: { + name: "parentField", + type: GraphQLString + } + }; + } + }); + return new GraphQLSchema({ + types: [ChildType, ParentType] + }); +} diff --git a/src/tests/fixtures/inheritance/classInheritsInterfaceFromParent.ts b/src/tests/fixtures/inheritance/classInheritsInterfaceFromParent.ts new file mode 100644 index 00000000..0d5ddba7 --- /dev/null +++ b/src/tests/fixtures/inheritance/classInheritsInterfaceFromParent.ts @@ -0,0 +1,16 @@ +/** @gqlInterface */ +interface MyInterface { + /** @gqlField */ + parentField: string; +} + +/** @gqlType */ +export class Parent implements MyInterface { + parentField: string; +} + +/** @gqlType */ +export class Child extends Parent { + /** @gqlField */ + childField: string; +} diff --git a/src/tests/fixtures/inheritance/classInheritsInterfaceFromParent.ts.expected b/src/tests/fixtures/inheritance/classInheritsInterfaceFromParent.ts.expected new file mode 100644 index 00000000..3e0bf05b --- /dev/null +++ b/src/tests/fixtures/inheritance/classInheritsInterfaceFromParent.ts.expected @@ -0,0 +1,106 @@ +----------------- +INPUT +----------------- +/** @gqlInterface */ +interface MyInterface { + /** @gqlField */ + parentField: string; +} + +/** @gqlType */ +export class Parent implements MyInterface { + parentField: string; +} + +/** @gqlType */ +export class Child extends Parent { + /** @gqlField */ + childField: string; +} + +----------------- +OUTPUT +----------------- +-- SDL -- +interface MyInterface { + parentField: String @metadata +} + +type Child implements MyInterface { + childField: String @metadata + parentField: String @metadata +} + +type Parent implements MyInterface { + parentField: String @metadata +} +-- TypeScript -- +import { Child as ChildClass } from "./classInheritsInterfaceFromParent"; +import { Parent as ParentClass } from "./classInheritsInterfaceFromParent"; +import { GraphQLSchema, GraphQLInterfaceType, GraphQLString, GraphQLObjectType } from "graphql"; +export function getSchema(): GraphQLSchema { + const MyInterfaceType: GraphQLInterfaceType = new GraphQLInterfaceType({ + name: "MyInterface", + fields() { + return { + parentField: { + name: "parentField", + type: GraphQLString + } + }; + }, + resolveType + }); + const ChildType: GraphQLObjectType = new GraphQLObjectType({ + name: "Child", + fields() { + return { + childField: { + name: "childField", + type: GraphQLString + }, + parentField: { + name: "parentField", + type: GraphQLString + } + }; + }, + interfaces() { + return [MyInterfaceType]; + } + }); + const ParentType: GraphQLObjectType = new GraphQLObjectType({ + name: "Parent", + fields() { + return { + parentField: { + name: "parentField", + type: GraphQLString + } + }; + }, + interfaces() { + return [MyInterfaceType]; + } + }); + return new GraphQLSchema({ + types: [MyInterfaceType, ChildType, ParentType] + }); +} +const typeNameMap = new Map(); +typeNameMap.set(ChildClass, "Child"); +typeNameMap.set(ParentClass, "Parent"); +function resolveType(obj: any): string { + if (typeof obj.__typename === "string") { + return obj.__typename; + } + let prototype = Object.getPrototypeOf(obj); + while (prototype) { + const name = typeNameMap.get(prototype.constructor); + if (name != null) { + return name; + } + prototype = Object.getPrototypeOf(prototype); + } + throw new Error("Cannot find type name."); +} diff --git a/src/tests/fixtures/inheritance/classInheritsInterfaceFromParentButIsMissingTypeName.invalid.ts b/src/tests/fixtures/inheritance/classInheritsInterfaceFromParentButIsMissingTypeName.invalid.ts new file mode 100644 index 00000000..51b2d24d --- /dev/null +++ b/src/tests/fixtures/inheritance/classInheritsInterfaceFromParentButIsMissingTypeName.invalid.ts @@ -0,0 +1,19 @@ +/** @gqlInterface */ +interface MyInterface { + /** @gqlField */ + parentField: string; +} + +/** @gqlType */ +export class Parent implements MyInterface { + parentField: string; +} + +/** @gqlType */ +export class Child extends Parent { + /** @gqlField */ + childField: string; +} + +// Note: We use `export` on the above classes to avoid issues with `__typename` being required to be a string literal +// on both which would clash. diff --git a/src/tests/fixtures/inheritance/classInheritsInterfaceFromParentButIsMissingTypeName.invalid.ts.expected b/src/tests/fixtures/inheritance/classInheritsInterfaceFromParentButIsMissingTypeName.invalid.ts.expected new file mode 100644 index 00000000..014f29f1 --- /dev/null +++ b/src/tests/fixtures/inheritance/classInheritsInterfaceFromParentButIsMissingTypeName.invalid.ts.expected @@ -0,0 +1,109 @@ +----------------- +INPUT +----------------- +/** @gqlInterface */ +interface MyInterface { + /** @gqlField */ + parentField: string; +} + +/** @gqlType */ +export class Parent implements MyInterface { + parentField: string; +} + +/** @gqlType */ +export class Child extends Parent { + /** @gqlField */ + childField: string; +} + +// Note: We use `export` on the above classes to avoid issues with `__typename` being required to be a string literal +// on both which would clash. + +----------------- +OUTPUT +----------------- +-- SDL -- +interface MyInterface { + parentField: String @metadata +} + +type Child implements MyInterface { + childField: String @metadata + parentField: String @metadata +} + +type Parent implements MyInterface { + parentField: String @metadata +} +-- TypeScript -- +import { Child as ChildClass } from "./classInheritsInterfaceFromParentButIsMissingTypeName.invalid"; +import { Parent as ParentClass } from "./classInheritsInterfaceFromParentButIsMissingTypeName.invalid"; +import { GraphQLSchema, GraphQLInterfaceType, GraphQLString, GraphQLObjectType } from "graphql"; +export function getSchema(): GraphQLSchema { + const MyInterfaceType: GraphQLInterfaceType = new GraphQLInterfaceType({ + name: "MyInterface", + fields() { + return { + parentField: { + name: "parentField", + type: GraphQLString + } + }; + }, + resolveType + }); + const ChildType: GraphQLObjectType = new GraphQLObjectType({ + name: "Child", + fields() { + return { + childField: { + name: "childField", + type: GraphQLString + }, + parentField: { + name: "parentField", + type: GraphQLString + } + }; + }, + interfaces() { + return [MyInterfaceType]; + } + }); + const ParentType: GraphQLObjectType = new GraphQLObjectType({ + name: "Parent", + fields() { + return { + parentField: { + name: "parentField", + type: GraphQLString + } + }; + }, + interfaces() { + return [MyInterfaceType]; + } + }); + return new GraphQLSchema({ + types: [MyInterfaceType, ChildType, ParentType] + }); +} +const typeNameMap = new Map(); +typeNameMap.set(ChildClass, "Child"); +typeNameMap.set(ParentClass, "Parent"); +function resolveType(obj: any): string { + if (typeof obj.__typename === "string") { + return obj.__typename; + } + let prototype = Object.getPrototypeOf(obj); + while (prototype) { + const name = typeNameMap.get(prototype.constructor); + if (name != null) { + return name; + } + prototype = Object.getPrototypeOf(prototype); + } + throw new Error("Cannot find type name."); +} diff --git a/src/tests/fixtures/inheritance/interfaceInheritsFieldFromParents.ts b/src/tests/fixtures/inheritance/interfaceInheritsFieldFromParents.ts new file mode 100644 index 00000000..df8f1d67 --- /dev/null +++ b/src/tests/fixtures/inheritance/interfaceInheritsFieldFromParents.ts @@ -0,0 +1,17 @@ +/** @gqlInterface */ +interface A { + /** @gqlField */ + aField: string; +} + +/** @gqlInterface */ +interface B { + /** @gqlField */ + bField: string; +} + +/** @gqlInterface */ +interface C extends A, B { + aField: string; + bField: string; +} diff --git a/src/tests/fixtures/inheritance/interfaceInheritsFieldFromParents.ts.expected b/src/tests/fixtures/inheritance/interfaceInheritsFieldFromParents.ts.expected new file mode 100644 index 00000000..722bf2a1 --- /dev/null +++ b/src/tests/fixtures/inheritance/interfaceInheritsFieldFromParents.ts.expected @@ -0,0 +1,64 @@ +----------------- +INPUT +----------------- +/** @gqlInterface */ +interface A { + /** @gqlField */ + aField: string; +} + +/** @gqlInterface */ +interface B { + /** @gqlField */ + bField: string; +} + +/** @gqlInterface */ +interface C extends A, B { + aField: string; + bField: string; +} + +----------------- +OUTPUT +----------------- +src/tests/fixtures/inheritance/interfaceInheritsFieldFromParents.ts:14:1 - error: Type C must define one or more fields. + +14 interface C extends A, B { + ~~~~~~~~~~~~~~~~~~~~~~~~~~ +15 aField: string; + ~~~~~~~~~~~~~~~~~ +16 bField: string; + ~~~~~~~~~~~~~~~~~ +17 } + ~ +src/tests/fixtures/inheritance/interfaceInheritsFieldFromParents.ts:4:3 - error: Interface field A.aField expected but C does not provide it. + +4 aField: string; + ~~~~~~~~~~~~~~~ + + src/tests/fixtures/inheritance/interfaceInheritsFieldFromParents.ts:14:1 + 14 interface C extends A, B { + ~~~~~~~~~~~~~~~~~~~~~~~~~~ + 15 aField: string; + ~~~~~~~~~~~~~~~~~ + 16 bField: string; + ~~~~~~~~~~~~~~~~~ + 17 } + ~ + Related location +src/tests/fixtures/inheritance/interfaceInheritsFieldFromParents.ts:10:3 - error: Interface field B.bField expected but C does not provide it. + +10 bField: string; + ~~~~~~~~~~~~~~~ + + src/tests/fixtures/inheritance/interfaceInheritsFieldFromParents.ts:14:1 + 14 interface C extends A, B { + ~~~~~~~~~~~~~~~~~~~~~~~~~~ + 15 aField: string; + ~~~~~~~~~~~~~~~~~ + 16 bField: string; + ~~~~~~~~~~~~~~~~~ + 17 } + ~ + Related location diff --git a/src/tests/fixtures/interfaces/ClassInterfaceImplementsInterface.ts b/src/tests/fixtures/interfaces/ClassInterfaceImplementsInterface.ts new file mode 100644 index 00000000..6e47532f --- /dev/null +++ b/src/tests/fixtures/interfaces/ClassInterfaceImplementsInterface.ts @@ -0,0 +1,18 @@ +/** @gqlInterface Node */ +interface GqlNode { + /** @gqlField */ + id: string; +} + +/** @gqlInterface */ +interface Person extends GqlNode { + id: string; + /** @gqlField */ + name: string; +} + +/** @gqlInterface */ +class Actor implements GqlNode, Person { + id: string; + name: string; +} diff --git a/src/tests/fixtures/interfaces/ClassInterfaceImplementsInterface.ts.expected b/src/tests/fixtures/interfaces/ClassInterfaceImplementsInterface.ts.expected new file mode 100644 index 00000000..1420820c --- /dev/null +++ b/src/tests/fixtures/interfaces/ClassInterfaceImplementsInterface.ts.expected @@ -0,0 +1,35 @@ +----------------- +INPUT +----------------- +/** @gqlInterface Node */ +interface GqlNode { + /** @gqlField */ + id: string; +} + +/** @gqlInterface */ +interface Person extends GqlNode { + id: string; + /** @gqlField */ + name: string; +} + +/** @gqlInterface */ +class Actor implements GqlNode, Person { + id: string; + name: string; +} + +----------------- +OUTPUT +----------------- +src/tests/fixtures/interfaces/ClassInterfaceImplementsInterface.ts:15:1 - error: Expected `@gqlInterface` class to be abstract. `@gqlInterface` can only be used on interface or abstract class declarations. e.g. `interface MyInterface {}` or `abstract class MyInterface {}` + +15 class Actor implements GqlNode, Person { + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +16 id: string; + ~~~~~~~~~~~~~ +17 name: string; + ~~~~~~~~~~~~~~~ +18 } + ~ diff --git a/src/tests/fixtures/interfaces/TagAttachedToWrongNode.ts.expected b/src/tests/fixtures/interfaces/TagAttachedToWrongNode.ts.expected index dcb94a3c..9cb751d6 100644 --- a/src/tests/fixtures/interfaces/TagAttachedToWrongNode.ts.expected +++ b/src/tests/fixtures/interfaces/TagAttachedToWrongNode.ts.expected @@ -7,7 +7,7 @@ function Foo() {} ----------------- OUTPUT ----------------- -src/tests/fixtures/interfaces/TagAttachedToWrongNode.ts:1:5 - error: `@gqlInterface` can only be used on interface declarations. e.g. `interface MyInterface {}` +src/tests/fixtures/interfaces/TagAttachedToWrongNode.ts:1:5 - error: `@gqlInterface` can only be used on interface or abstract class declarations. e.g. `interface MyInterface {}` or `abstract class MyInterface {}` 1 /** @gqlInterface Person */ ~~~~~~~~~~~~~~~~~~~~~ diff --git a/src/tests/fixtures/interfaces/abstractTypeDefinesInterface.ts b/src/tests/fixtures/interfaces/abstractTypeDefinesInterface.ts new file mode 100644 index 00000000..4cae53d1 --- /dev/null +++ b/src/tests/fixtures/interfaces/abstractTypeDefinesInterface.ts @@ -0,0 +1,27 @@ +import { ID } from "../../../Types"; + +/** @gqlInterface Node */ +abstract class GraphQLNode { + __typename: string; + localId: string; + + /** + * @gqlField + * @killsParentOnException + */ + id(): ID { + return window.btoa(this.__typename + ":" + this.localId); + } +} + +/** @gqlType */ +class User extends GraphQLNode { + __typename: "User"; + constructor( + public localId: string, + /** @gqlField */ + public name: string, + ) { + super(); + } +} diff --git a/src/tests/fixtures/interfaces/abstractTypeDefinesInterface.ts.expected b/src/tests/fixtures/interfaces/abstractTypeDefinesInterface.ts.expected new file mode 100644 index 00000000..548744af --- /dev/null +++ b/src/tests/fixtures/interfaces/abstractTypeDefinesInterface.ts.expected @@ -0,0 +1,76 @@ +----------------- +INPUT +----------------- +import { ID } from "../../../Types"; + +/** @gqlInterface Node */ +abstract class GraphQLNode { + __typename: string; + localId: string; + + /** + * @gqlField + * @killsParentOnException + */ + id(): ID { + return window.btoa(this.__typename + ":" + this.localId); + } +} + +/** @gqlType */ +class User extends GraphQLNode { + __typename: "User"; + constructor( + public localId: string, + /** @gqlField */ + public name: string, + ) { + super(); + } +} + +----------------- +OUTPUT +----------------- +-- SDL -- +interface Node { + id: ID! @killsParentOnException @metadata +} + +type User { + id: ID! @killsParentOnException @metadata + name: String @metadata +} +-- TypeScript -- +import { GraphQLSchema, GraphQLInterfaceType, GraphQLNonNull, GraphQLID, GraphQLObjectType, GraphQLString } from "graphql"; +export function getSchema(): GraphQLSchema { + const NodeType: GraphQLInterfaceType = new GraphQLInterfaceType({ + name: "Node", + fields() { + return { + id: { + name: "id", + type: new GraphQLNonNull(GraphQLID) + } + }; + } + }); + const UserType: GraphQLObjectType = new GraphQLObjectType({ + name: "User", + fields() { + return { + id: { + name: "id", + type: new GraphQLNonNull(GraphQLID) + }, + name: { + name: "name", + type: GraphQLString + } + }; + } + }); + return new GraphQLSchema({ + types: [NodeType, UserType] + }); +} diff --git a/src/tests/fixtures/interfaces/classInterfaceNamedQuery.invalid.ts b/src/tests/fixtures/interfaces/classInterfaceNamedQuery.invalid.ts new file mode 100644 index 00000000..5d05686e --- /dev/null +++ b/src/tests/fixtures/interfaces/classInterfaceNamedQuery.invalid.ts @@ -0,0 +1,5 @@ +/** @gqlInterface */ +class Query { + /** @gqlField */ + field: string; +} diff --git a/src/tests/fixtures/interfaces/classInterfaceNamedQuery.invalid.ts.expected b/src/tests/fixtures/interfaces/classInterfaceNamedQuery.invalid.ts.expected new file mode 100644 index 00000000..224231f2 --- /dev/null +++ b/src/tests/fixtures/interfaces/classInterfaceNamedQuery.invalid.ts.expected @@ -0,0 +1,22 @@ +----------------- +INPUT +----------------- +/** @gqlInterface */ +class Query { + /** @gqlField */ + field: string; +} + +----------------- +OUTPUT +----------------- +src/tests/fixtures/interfaces/classInterfaceNamedQuery.invalid.ts:2:1 - error: Expected `@gqlInterface` class to be abstract. `@gqlInterface` can only be used on interface or abstract class declarations. e.g. `interface MyInterface {}` or `abstract class MyInterface {}` + +2 class Query { + ~~~~~~~~~~~~~ +3 /** @gqlField */ + ~~~~~~~~~~~~~~~~~~ +4 field: string; + ~~~~~~~~~~~~~~~~ +5 } + ~ diff --git a/src/tests/fixtures/interfaces/interfaceNamedQuery.invalid.ts b/src/tests/fixtures/interfaces/interfaceNamedQuery.invalid.ts new file mode 100644 index 00000000..a22ba359 --- /dev/null +++ b/src/tests/fixtures/interfaces/interfaceNamedQuery.invalid.ts @@ -0,0 +1,5 @@ +/** @gqlInterface */ +interface Query { + /** @gqlField */ + field: string; +} diff --git a/src/tests/fixtures/interfaces/interfaceNamedQuery.invalid.ts.expected b/src/tests/fixtures/interfaces/interfaceNamedQuery.invalid.ts.expected new file mode 100644 index 00000000..37149e2b --- /dev/null +++ b/src/tests/fixtures/interfaces/interfaceNamedQuery.invalid.ts.expected @@ -0,0 +1,22 @@ +----------------- +INPUT +----------------- +/** @gqlInterface */ +interface Query { + /** @gqlField */ + field: string; +} + +----------------- +OUTPUT +----------------- +src/tests/fixtures/interfaces/interfaceNamedQuery.invalid.ts:2:1 - error: Query root type must be Object type, it cannot be Query. + +2 interface Query { + ~~~~~~~~~~~~~~~~~ +3 /** @gqlField */ + ~~~~~~~~~~~~~~~~~~ +4 field: string; + ~~~~~~~~~~~~~~~~ +5 } + ~ diff --git a/src/transforms/addInterfaceFields.ts b/src/transforms/addInterfaceFields.ts index b7a0dc97..62bedb6f 100644 --- a/src/transforms/addInterfaceFields.ts +++ b/src/transforms/addInterfaceFields.ts @@ -9,44 +9,36 @@ import { gqlRelated, } from "../utils/DiagnosticError"; import { err, ok } from "../utils/Result"; -import { InterfaceMap, computeInterfaceMap } from "../InterfaceGraph"; -import { extend, nullThrows, uniqueId } from "../utils/helpers"; +import { nullThrows } from "../utils/helpers"; import { FIELD_TAG } from "../Extractor"; -import { FIELD_METADATA_DIRECTIVE } from "../metadataDirectives"; /** * Grats allows you to define GraphQL fields on TypeScript interfaces using - * function syntax. This allows you to define a shared implementation for - * all types that implement the interface. + * function syntax. This means when we extract a function field we don't know + * yet if it is extending a type or an interface. * - * This transform takes those abstract field definitions, and adds them to - * the concrete types that implement the interface. + * This transform takes those abstract field definitions, transforms them into + * either object type extensions or interface type extensions. */ export function addInterfaceFields( ctx: TypeContext, docs: DefinitionNode[], ): DiagnosticsResult { - const newDocs: DefinitionNode[] = []; const errors: ts.DiagnosticWithLocation[] = []; - const interfaceGraph = computeInterfaceMap(ctx, docs); - - for (const doc of docs) { + const newDocs = docs.map((doc) => { if (doc.kind === Kind.OBJECT_TYPE_EXTENSION && doc.mayBeInterface) { - const abstractDocResults = addAbstractFieldDefinition( - ctx, - doc, - interfaceGraph, - ); + const abstractDocResults = addAbstractFieldDefinition(ctx, doc); if (abstractDocResults.kind === "ERROR") { errors.push(abstractDocResults.err); + return doc; } else { - extend(newDocs, abstractDocResults.value); + return abstractDocResults.value; } } else { - newDocs.push(doc); + return doc; } - } + }); if (errors.length > 0) { return err(errors); } @@ -58,9 +50,7 @@ export function addInterfaceFields( function addAbstractFieldDefinition( ctx: TypeContext, doc: ObjectTypeExtensionNode, - interfaceGraph: InterfaceMap, -): DiagnosticResult { - const newDocs: DefinitionNode[] = []; +): DiagnosticResult { const definitionResult = ctx.gqlNameDefinitionForGqlName(doc.name); if (definitionResult.kind === "ERROR") { @@ -72,56 +62,19 @@ function addAbstractFieldDefinition( switch (nameDefinition.kind) { case "TYPE": - // Extending a type, is just adding a field to it. - newDocs.push({ + return ok({ kind: Kind.OBJECT_TYPE_EXTENSION, name: doc.name, fields: [field], loc: doc.loc, }); - break; case "INTERFACE": { - // Extending an interface is a bit more complicated. We need to add the field - // to the interface, and to each type that implements the interface. - - // The interface field definition is not executable, so we don't - // need to annotate it with the details of the implementation. - const directives = field.directives?.filter((directive) => { - return directive.name.value !== FIELD_METADATA_DIRECTIVE; - }); - newDocs.push({ + return ok({ kind: Kind.INTERFACE_TYPE_EXTENSION, name: doc.name, - fields: [{ ...field, directives }], + fields: [field], + loc: doc.loc, }); - - for (const implementor of interfaceGraph.get(nameDefinition.name.value)) { - const name = { - kind: Kind.NAME, - value: implementor.name, - loc: doc.loc, // Bit of a lie, but I don't see a better option. - tsIdentifier: uniqueId(), - } as const; - switch (implementor.kind) { - case "TYPE": - newDocs.push({ - kind: Kind.OBJECT_TYPE_EXTENSION, - name, - fields: [field], - loc: doc.loc, - }); - break; - case "INTERFACE": - newDocs.push({ - kind: Kind.INTERFACE_TYPE_EXTENSION, - name, - fields: [{ ...field, directives }], - loc: doc.loc, - }); - break; - } - } - break; } default: { // Extending any other type of definition is not supported. @@ -138,5 +91,4 @@ function addAbstractFieldDefinition( ); } } - return ok(newDocs); } diff --git a/src/transforms/propagateHeritage.ts b/src/transforms/propagateHeritage.ts new file mode 100644 index 00000000..b1b4ba85 --- /dev/null +++ b/src/transforms/propagateHeritage.ts @@ -0,0 +1,129 @@ +/** + * If a class or interface extends or implements another class or interface, it + * should also inherit all of the parent's properties and methods. Note that + * this is recursive as well. + */ + +import * as ts from "typescript"; +import { + DocumentNode, + FieldDefinitionNode, + InterfaceTypeDefinitionNode, + Kind, + NamedTypeNode, + ObjectTypeDefinitionNode, +} from "graphql"; +import { NameDefinition, TypeContext } from "../TypeContext"; +import { nullThrows } from "../utils/helpers"; + +/** + * If a class or interface extends or implements another class or interface, we + * should also (recursively!) inherit all of the parent's GraphQL fields and interfaces. + * + * This function adds all fields and interfaces from parent classes and interfaces to the + * child class or interface. + */ +export function propagateHeritage( + ctx: TypeContext, + documentNode: DocumentNode, +): DocumentNode { + const propagator = new HeritagePropagator(ctx, documentNode); + return propagator.propagateHeritage(); +} + +class HeritagePropagator { + _definitionsByName: Map< + string, + InterfaceTypeDefinitionNode | ObjectTypeDefinitionNode + > = new Map(); + + constructor(private ctx: TypeContext, private documentNode: DocumentNode) { + for (const def of this.documentNode.definitions) { + switch (def.kind) { + case Kind.INTERFACE_TYPE_DEFINITION: + case Kind.OBJECT_TYPE_DEFINITION: { + this._definitionsByName.set(def.name.value, def); + break; + } + } + } + } + + propagateHeritage(): DocumentNode { + const newDefinitions = this.documentNode.definitions.map((def) => { + switch (def.kind) { + case Kind.INTERFACE_TYPE_DEFINITION: + case Kind.OBJECT_TYPE_DEFINITION: { + const declaration = this.ctx.tsDeclarationForGqlDefinition(def); + if ( + ts.isClassDeclaration(declaration) || + ts.isInterfaceDeclaration(declaration) + ) { + return this.propagateHeritageForDefinition(def, declaration); + } + return def; + } + default: + return def; + } + }); + return { ...this.documentNode, definitions: newDefinitions }; + } + + propagateHeritageForDefinition( + def: InterfaceTypeDefinitionNode | ObjectTypeDefinitionNode, + declaration: ts.ClassDeclaration | ts.InterfaceDeclaration, + ): InterfaceTypeDefinitionNode | ObjectTypeDefinitionNode { + const name = nullThrows(declaration.name); + const parentTypes = this.ctx.getAllParentClassesForName(name); + + // Build up fields + const fieldsMap = new Map(); + const ownFields = new Set(); + if (def.fields != null) { + for (const field of def.fields) { + ownFields.add(field.name.value); + } + } + for (const parent of parentTypes) { + for (const field of this.fieldsForType(parent)) { + if ( + !fieldsMap.has(field.name.value) && + !ownFields.has(field.name.value) + ) { + fieldsMap.set(field.name.value, field); + } + } + } + + // Build up interfaces + const interfaces = def.interfaces == null ? [] : [...def.interfaces]; + + for (const parent of parentTypes) { + for (const parentInterface of this.interfacesForType(parent)) { + if ( + !interfaces.some((i) => i.name.value === parentInterface.name.value) + ) { + interfaces.push(parentInterface); + } + } + } + + const parentFields = fieldsMap.values(); + + const fields = + def.fields == null ? [...parentFields] : [...def.fields, ...parentFields]; + + return { ...def, interfaces, fields }; + } + + fieldsForType(name: NameDefinition): readonly FieldDefinitionNode[] { + const def = this._definitionsByName.get(name.name.value); + return def == null ? [] : def.fields ?? []; + } + + interfacesForType(name: NameDefinition): readonly NamedTypeNode[] { + const def = this._definitionsByName.get(name.name.value); + return def == null ? [] : def.interfaces ?? []; + } +} diff --git a/website/docs/04-docblock-tags/01-types.mdx b/website/docs/04-docblock-tags/01-types.mdx index aed688d9..914336cd 100644 --- a/website/docs/04-docblock-tags/01-types.mdx +++ b/website/docs/04-docblock-tags/01-types.mdx @@ -14,7 +14,7 @@ GraphQL types can be defined by placing a `@gqlType` docblock directly before a: - Interface declaration - Type alias of a literal type or `unknown` -If model your GraphQL resolvers using classes, simply add a `@gqlType` docblock +To model your GraphQL resolvers using classes, simply add a `@gqlType` docblock before the class containing that type's resolvers. @@ -26,6 +26,10 @@ an interface. +:::info +Types declared using TypeScript classes or interfaces will inherit GraphQL fields and interfaces from all classes/interfaces they extend/implement. +::: + Finally, if your types are represented in your code by named types, simply add a `@gqlType` docblock before the type alias which describes that type. @@ -73,7 +77,7 @@ Will generate the following GraphQL schema: -#### Type Alias +### Type Alias Types declared using a type alias _may not_ implement a GraphQL interface. Instead, we recommend using a TypeScript interface to model your GraphQL type. @@ -84,5 +88,5 @@ See [Interfaces](./05-interfaces.mdx) for more information about defining interf ::: :::note -Grats must be able to determine the typename of any type which implements an interface. To achieve this Grats will validate that all implementors of an interface either define a `__typename: "MyType" as const` property or are exported classes. Grats can use either to determin the typename at runtime. +Grats must be able to determine the typename of any type which implements an interface. To achieve this Grats will validate that all implementors of an interface either define a `__typename: "MyType" as const` property or are exported classes. Grats can use either to determine the typename at runtime. ::: diff --git a/website/docs/04-docblock-tags/05-interfaces.mdx b/website/docs/04-docblock-tags/05-interfaces.mdx index e1f8efe1..ea56af23 100644 --- a/website/docs/04-docblock-tags/05-interfaces.mdx +++ b/website/docs/04-docblock-tags/05-interfaces.mdx @@ -1,5 +1,6 @@ import GratsCode from "@site/src/components/GratsCode"; import InterfaceDeclaration from "!!raw-loader!./snippets/04-interface-declaration.out"; +import InterfaceClassDeclaration from "!!raw-loader!./snippets/04-interface-class-declaration.out"; import NodeInterface from "!!raw-loader!./snippets/04-merged-interface-renaming.out"; import InterfaceImplementingInterface from "!!raw-loader!./snippets/04-interface-implement-interface.out"; import InterfaceFieldCommonImpl from "!!raw-loader!./snippets/04-interface-field-common-impl.out"; @@ -9,12 +10,21 @@ import InterfaceFieldCommonImpl from "!!raw-loader!./snippets/04-interface-field GraphQL interfaces can be defined by placing a `@gqlInterface` docblock directly before an: - Interface declaration +- Class declaration - +To model your GraphQL interface using classes, simply add a `@gqlInterface` docblock +before the class. Note that with this approach, implementors of the interface will also inherit the field methods. + + -## Shared Field Implementation +If your types are represented in your code by TypeScript interfaces, simply add a +`@gqlType` docblock before the interface representing that type. Note that by +using `@gqlType` on an interface, Grats will treat it as a GraphQL type and not +an interface. -If you wish to define field which has a single implementation that is shared by all implementors, you can use the [function style of `@gqlField`](./02-fields.mdx#functional-style-fields) to define the field. This will automatically add the field to all implementors of the interface. + + +If you wish to provide shared implementations for fields in an interface defined in this way, you can use the [functional style fields](./02-fields.mdx#functional-style-fields). @@ -32,10 +42,8 @@ Which will generate the following GraphQL schema: ---- - -:::note -Each implementor of an interface must declare define all the fields required by the interface with `/** @gqlField */`. This means that if you have an interface that implements another interface, you must define all the fields required by both interfaces. +:::info +Interfaces will inherit GraphQL fields and interfaces from all interfaces they extend. ::: ## Merged Interfaces diff --git a/website/docs/04-docblock-tags/06-unions.mdx b/website/docs/04-docblock-tags/06-unions.mdx index eee510e9..93db72eb 100644 --- a/website/docs/04-docblock-tags/06-unions.mdx +++ b/website/docs/04-docblock-tags/06-unions.mdx @@ -36,5 +36,5 @@ All the types referenced in the TypeScript union but be explicitly annotated wit ::: :::note -Grats must be able to determine the typename of any type which members of a union. To achieve this Grats will validate that all member types either define a `__typename: "MyType" as const` property or are exported classes. Grats can use either to determin the typename at runtime. +Grats must be able to determine the typename of any type which members of a union. To achieve this Grats will validate that all member types either define a `__typename: "MyType" as const` property or are exported classes. Grats can use either to determine the typename at runtime. ::: diff --git a/website/docs/04-docblock-tags/snippets/04-interface-class-declaration.grats.ts b/website/docs/04-docblock-tags/snippets/04-interface-class-declaration.grats.ts new file mode 100644 index 00000000..1a3b6d91 --- /dev/null +++ b/website/docs/04-docblock-tags/snippets/04-interface-class-declaration.grats.ts @@ -0,0 +1,7 @@ +/** @gqlInterface */ +class MyInterfaceClass { + /** @gqlField */ + someField(): string { + return "someField"; + } +} diff --git a/website/docs/04-docblock-tags/snippets/04-interface-class-declaration.out b/website/docs/04-docblock-tags/snippets/04-interface-class-declaration.out new file mode 100644 index 00000000..ba6b9a99 --- /dev/null +++ b/website/docs/04-docblock-tags/snippets/04-interface-class-declaration.out @@ -0,0 +1,30 @@ +/** @gqlInterface */ +class MyInterfaceClass { + /** @gqlField */ + someField(): string { + return "someField"; + } +} + +=== SNIP === +interface MyInterfaceClass { + someField: String +} +=== SNIP === +import { GraphQLSchema, GraphQLInterfaceType, GraphQLString } from "graphql"; +export function getSchema(): GraphQLSchema { + const MyInterfaceClassType: GraphQLInterfaceType = new GraphQLInterfaceType({ + name: "MyInterfaceClass", + fields() { + return { + someField: { + name: "someField", + type: GraphQLString + } + }; + } + }); + return new GraphQLSchema({ + types: [MyInterfaceClassType] + }); +} diff --git a/website/docs/04-docblock-tags/snippets/04-interface-declaration.grats.ts b/website/docs/04-docblock-tags/snippets/04-interface-declaration.grats.ts index 00fa0b15..20b0fb56 100644 --- a/website/docs/04-docblock-tags/snippets/04-interface-declaration.grats.ts +++ b/website/docs/04-docblock-tags/snippets/04-interface-declaration.grats.ts @@ -2,7 +2,7 @@ * A description of my interface. * @gqlInterface MyInterfaceName */ -interface MyClass { +interface MyInterface { /** @gqlField */ someField: string; } diff --git a/website/docs/04-docblock-tags/snippets/04-interface-declaration.out b/website/docs/04-docblock-tags/snippets/04-interface-declaration.out index 97db0c08..2a3c4adf 100644 --- a/website/docs/04-docblock-tags/snippets/04-interface-declaration.out +++ b/website/docs/04-docblock-tags/snippets/04-interface-declaration.out @@ -2,7 +2,7 @@ * A description of my interface. * @gqlInterface MyInterfaceName */ -interface MyClass { +interface MyInterface { /** @gqlField */ someField: string; } diff --git a/website/docs/04-docblock-tags/snippets/04-interface-implement-interface.grats.ts b/website/docs/04-docblock-tags/snippets/04-interface-implement-interface.grats.ts index 6add05a9..5da5ceb5 100644 --- a/website/docs/04-docblock-tags/snippets/04-interface-implement-interface.grats.ts +++ b/website/docs/04-docblock-tags/snippets/04-interface-implement-interface.grats.ts @@ -8,7 +8,6 @@ interface Person { // highlight-start interface User extends Person { // highlight-end - /** @gqlField */ name: string; /** @gqlField */ diff --git a/website/docs/04-docblock-tags/snippets/04-interface-implement-interface.out b/website/docs/04-docblock-tags/snippets/04-interface-implement-interface.out index 1991858e..55e6fe69 100644 --- a/website/docs/04-docblock-tags/snippets/04-interface-implement-interface.out +++ b/website/docs/04-docblock-tags/snippets/04-interface-implement-interface.out @@ -8,7 +8,6 @@ interface Person { // highlight-start interface User extends Person { // highlight-end - /** @gqlField */ name: string; /** @gqlField */ diff --git a/website/docs/05-guides/09-inheritance.mdx b/website/docs/05-guides/09-inheritance.mdx new file mode 100644 index 00000000..f20df078 --- /dev/null +++ b/website/docs/05-guides/09-inheritance.mdx @@ -0,0 +1,35 @@ +# Inheritance + +Because Grats marries the worlds of TypeScript and GraphQL ends up implementing a version of inheritance that is something of a hybrid between the two. This guide explains the principles of inheritance in Grats and shows practical examples of how it works in practice. + +Lets start with a reminder of how inheritnace works in TypeScript and GraphQL respectively and then we'll see how Grats combines the two. + +## TypeScript Inheritance + +TypeScript implemenets single inheritance, meaning that a class can only inherit from at most one other class. This is done using the `extends` keyword. When a class extends another class, it inherits all the fields and methods of the parent class. Note that this works recursively. + +Classes in TypeScript may also implement interfaces. While a class may implement multiple interfaces, the interfaces are simply type-checking contracts and do not add any fields or methods to the class. + +## GraphQL Inheritance + +GraphQL does not have any notion of inheritance. While GraphQL types and interfacs can implement interfaces, implementing an interface does not automatically add any fields to the type. It's simply a type-checking contract, and each type or interface must explicitly define the fields required by any interfaces it implements. + +Similarly, if a type implements an iterface with itself implements a second interface, the type must explicitly declare that it also implements that second interface, or else it will trigger a validation error. + +## Grats Inheritance + +Grats tries to match the semantics of TypeScript as much as possible while still generating a valid GraphQL schema. + +If a `@gqlType` class extends another class, Grats will automatically infer that any fields marked as `@gqlField` in the parent class should also be exposed in the child class. + +Similarly, if a `@gqlType` class implements interfaces, Grats will automatically infer that any fields marked as `@gqlField` in any of those interface should also be exposed on the class. + +Both of these are done recursively, so if a parent class implements an interface which extends another interface, the child class will inherit all the fields from both interfaces. + +### Overrides/Precedence + +In terms of runtime behavior, the code being executed is TypeScript and thus will match TypeScript's semantics. Methods on a child class will override methods on a parent class. + +If a field or method is marked as a `@gqlField` on both a parent class and a child class, the child class will take precedence. This can be useful if you want the child class to have a different GraphQL declaration from its parent. For example, you might want to add a different description, or one might be deprecated while the other is not, or the child might have a return type which is a subtype of the parent's return type. + +### Function style fields diff --git a/website/src/components/PlaygroundFeatures/defaultState.ts b/website/src/components/PlaygroundFeatures/defaultState.ts index cbe4894c..c34b2888 100644 --- a/website/src/components/PlaygroundFeatures/defaultState.ts +++ b/website/src/components/PlaygroundFeatures/defaultState.ts @@ -37,6 +37,7 @@ export const DEFAULT_STATE: State = { config: { nullableByDefault: true, reportTypeScriptTypeErrors: true, + importModuleSpecifierEnding: "", }, view: { outputOption: "sdl", diff --git a/website/src/components/PlaygroundFeatures/store.ts b/website/src/components/PlaygroundFeatures/store.ts index bebf36bf..16aa757e 100644 --- a/website/src/components/PlaygroundFeatures/store.ts +++ b/website/src/components/PlaygroundFeatures/store.ts @@ -10,6 +10,7 @@ export type State = { config: { nullableByDefault: boolean; reportTypeScriptTypeErrors: boolean; + importModuleSpecifierEnding: string; }; view: { showGratsDirectives: boolean;