Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
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
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
~~~~~~~~

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}!`;
}
Expand All @@ -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}!`;
}
Expand Down
Loading
Loading