Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make classes and interfaces inherit GraphQL fields and interfaces from classes they extend and interfaces they implement #145

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
"dependencies": {
"commander": "^10.0.0",
"graphql": "^16.9.0",
"typescript": "5.5.4"
"typescript": "5.5.4",
"semver": "^7.5.4"
},
"devDependencies": {
"@graphql-tools/utils": "^9.2.1",
Expand All @@ -33,7 +34,6 @@
"path-browserify": "^1.0.1",
"prettier": "^2.8.7",
"process": "^0.11.10",
"semver": "^7.5.4",
"ts-node": "^10.9.1"
},
"prettier": {
Expand Down
6 changes: 3 additions & 3 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion src/Errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
44 changes: 43 additions & 1 deletion src/Extractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down Expand Up @@ -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<ts.ClassElement | ts.TypeElement>,
): Array<FieldDefinitionNode> {
Expand Down
59 changes: 0 additions & 59 deletions src/InterfaceGraph.ts

This file was deleted.

55 changes: 55 additions & 0 deletions src/TypeContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<NameDefinition> {
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<NameDefinition> = new Set(),
): Set<NameDefinition> {
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<ts.HeritageClause> | null {
if (ts.isClassDeclaration(declaration)) {
return declaration.heritageClauses ?? null;
}
return null;
}
3 changes: 3 additions & 0 deletions src/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading
Loading