From a995481b1c707943f9d2d531524f355073069035 Mon Sep 17 00:00:00 2001 From: Mikhail Novikov Date: Wed, 11 Dec 2024 17:48:32 +0100 Subject: [PATCH 01/11] WIP --- TODO.md | 6 ++ src/Errors.ts | 4 ++ src/Extractor.ts | 66 ++++++++++++++----- src/GraphQLAstExtensions.ts | 5 ++ src/TypeContext.ts | 20 +++++- src/lib.ts | 25 +++---- src/tests/TestRunner.ts | 10 +-- .../externals/nonGratsPackage.ignore.ts | 7 ++ .../externals/nonGratsPackageWrapper.ts | 19 ++++++ .../nonGratsPackageWrapper.ts.expected | 0 src/tests/fixtures/externals/test-sdl.graphql | 7 ++ src/tests/test.ts | 19 +++--- src/transforms/makeResolverSignature.ts | 5 +- src/transforms/mergeExtensions.ts | 17 ++++- 14 files changed, 164 insertions(+), 46 deletions(-) create mode 100644 TODO.md create mode 100644 src/tests/fixtures/externals/nonGratsPackage.ignore.ts create mode 100644 src/tests/fixtures/externals/nonGratsPackageWrapper.ts create mode 100644 src/tests/fixtures/externals/nonGratsPackageWrapper.ts.expected create mode 100644 src/tests/fixtures/externals/test-sdl.graphql diff --git a/TODO.md b/TODO.md new file mode 100644 index 00000000..036d0049 --- /dev/null +++ b/TODO.md @@ -0,0 +1,6 @@ +- SDL should extend type for external types - I guess marking types in SDL +- all imported types +- Read SDL to actually do validation +- "modular" mode? like no full schema, but parts of schema but with full validation by resolving it? +- all tests to add fixtures for metadata/resolver map +- pluggable module resolution diff --git a/src/Errors.ts b/src/Errors.ts index c623b2a6..bd9c2f77 100644 --- a/src/Errors.ts +++ b/src/Errors.ts @@ -602,3 +602,7 @@ export function noTypesDefined() { export function tsConfigNotFound(cwd: string) { return `Grats: Could not find \`tsconfig.json\` searching in ${cwd}.\n\nSee https://www.typescriptlang.org/download/ for instructors on how to add TypeScript to your project. Then run \`npx tsc --init\` to create a \`tsconfig.json\` file.`; } + +export function noModuleInGqlExternal() { + return `Grats: @gqlExternal must include a module name in double quotes. For example: /** @gqlExternal "myModule" */`; +} diff --git a/src/Extractor.ts b/src/Extractor.ts index 3b443cfd..c29cea8e 100644 --- a/src/Extractor.ts +++ b/src/Extractor.ts @@ -58,6 +58,8 @@ export const INFO_TAG = "gqlInfo"; export const IMPLEMENTS_TAG_DEPRECATED = "gqlImplements"; export const KILLS_PARENT_ON_EXCEPTION_TAG = "killsParentOnException"; +export const EXTERNAL_TAG = "gqlExternal"; + // All the tags that start with gql export const ALL_TAGS = [ TYPE_TAG, @@ -67,6 +69,7 @@ export const ALL_TAGS = [ ENUM_TAG, UNION_TAG, INPUT_TAG, + EXTERNAL_TAG, ]; const DEPRECATED_TAG = "deprecated"; @@ -131,12 +134,12 @@ class Extractor { node: ts.DeclarationStatement, name: NameNode, kind: NameDefinition["kind"], + externalImportPath: string | null = null, ): void { - this.nameDefinitions.set(node, { name, kind }); + this.nameDefinitions.set(node, { name, kind, externalImportPath }); } - // Traverse all nodes, checking each one for its JSDoc tags. - // If we find a tag we recognize, we extract the relevant information, + // Traverse all nodes, checking each one for its JSDoc tags. // If we find a tag we recognize, we extract the relevant information, // reporting an error if it is attached to a node where that tag is not // supported. extract(sourceFile: ts.SourceFile): DiagnosticsResult { @@ -254,6 +257,12 @@ class Extractor { } break; } + case EXTERNAL_TAG: + if (!this.hasTag(node, TYPE_TAG)) { + this.report(tag.tagName, E.specifiedByOnWrongNode()); + } + break; + default: { const lowerCaseTag = tag.tagName.text.toLowerCase(); @@ -980,6 +989,7 @@ class Extractor { let interfaces: NamedTypeNode[] | null = null; let hasTypeName = false; + let externalImportPath: string | null = null; if (ts.isTypeLiteralNode(node.type)) { this.validateOperationTypes(node.type, name.value); @@ -990,24 +1000,33 @@ class Extractor { // This is fine, we just don't know what it is. This should be the expected // case for operation types such as `Query`, `Mutation`, and `Subscription` // where there is not strong convention around. + } else if ( + node.type.kind === ts.SyntaxKind.TypeReference && + this.hasTag(node, EXTERNAL_TAG) + ) { + const externalTag = this.findTag(node, EXTERNAL_TAG) as ts.JSDocTag; + externalImportPath = this.externalModule(node, externalTag); + console.log("DEBUG - External import path", externalImportPath); } else { return this.report(node.type, E.typeTagOnAliasOfNonObjectOrUnknown()); } const description = this.collectDescription(node); - this.recordTypeName(node, name, "TYPE"); + this.recordTypeName(node, name, "TYPE", externalImportPath); - this.definitions.push( - this.gql.objectTypeDefinition( - node, - name, - fields, - interfaces, - description, - hasTypeName, - null, - ), - ); + if (!externalImportPath) { + this.definitions.push( + this.gql.objectTypeDefinition( + node, + name, + fields, + interfaces, + description, + hasTypeName, + null, + ), + ); + } } checkForTypenameProperty( @@ -1784,6 +1803,23 @@ class Extractor { return this.gql.name(id, id.text); } + externalModule(node: ts.Node, tag: ts.JSDocTag) { + let externalModule; + if (tag.comment != null) { + const commentText = ts.getTextOfJSDocComment(tag.comment); + if (commentText) { + const match = commentText.match(/^\s*"(.*)"\s*$/); + if (match && match[0]) { + externalModule = match[0]; + } + } + } + if (!externalModule) { + return this.report(node, E.noModuleInGqlExternal()); + } + return externalModule; + } + methodDeclaration( node: ts.MethodDeclaration | ts.MethodSignature | ts.GetAccessorDeclaration, ): FieldDefinitionNode | null { diff --git a/src/GraphQLAstExtensions.ts b/src/GraphQLAstExtensions.ts index f9c45074..19250843 100644 --- a/src/GraphQLAstExtensions.ts +++ b/src/GraphQLAstExtensions.ts @@ -38,6 +38,7 @@ declare module "graphql" { exportName: string | null; }; } + export interface UnionTypeDefinitionNode { /** * Grats metadata: Indicates that the type was materialized as part of @@ -58,6 +59,10 @@ declare module "graphql" { * or a type. */ mayBeInterface?: boolean; + /** + * Grats metadata: Indicates whether this extension is for external type + */ + isOnExternalType?: boolean; } export interface FieldDefinitionNode { diff --git a/src/TypeContext.ts b/src/TypeContext.ts index 548d8473..17f61780 100644 --- a/src/TypeContext.ts +++ b/src/TypeContext.ts @@ -29,6 +29,7 @@ export type NameDefinition = { | "ENUM" | "CONTEXT" | "INFO"; + externalImportPath: string | null; }; type TsIdentifier = number; @@ -61,7 +62,12 @@ export class TypeContext { self._markUnresolvedType(node, typeName); } for (const [node, definition] of snapshot.nameDefinitions) { - self._recordTypeName(node, definition.name, definition.kind); + self._recordTypeName( + node, + definition.name, + definition.kind, + definition.externalImportPath, + ); } return self; } @@ -76,9 +82,10 @@ export class TypeContext { node: ts.Declaration, name: NameNode, kind: NameDefinition["kind"], + externalImportPath: string | null = null, ) { this._idToDeclaration.set(name.tsIdentifier, node); - this._declarationToName.set(node, { name, kind }); + this._declarationToName.set(node, { name, kind, externalImportPath }); } // Record that a type references `node` @@ -90,6 +97,15 @@ export class TypeContext { return this._declarationToName.values(); } + getNameDefinition(name: NameNode): NameDefinition | null { + for (const def of this.allNameDefinitions()) { + if (def.name.value === name.value) { + return def; + } + } + return null; + } + findSymbolDeclaration(startSymbol: ts.Symbol): ts.Declaration | null { const symbol = this.resolveSymbol(startSymbol); const declaration = symbol.declarations?.[0]; diff --git a/src/lib.ts b/src/lib.ts index 11a3f60c..ba87dc9e 100644 --- a/src/lib.ts +++ b/src/lib.ts @@ -114,9 +114,10 @@ export function extractSchemaAndDoc( // `@killsParentOnException`. .andThen((doc) => applyDefaultNullability(doc, config)) // Merge any `extend` definitions into their base definitions. - .map((doc) => mergeExtensions(doc)) + .map((doc) => mergeExtensions(ctx, doc)) // Perform custom validations that reimplement spec validation rules // with more tailored error messages. + // TODO .andThen((doc) => customSpecValidations(doc)) // Sort the definitions in the document to ensure a stable output. .map((doc) => sortSchemaAst(doc)) @@ -151,19 +152,19 @@ function buildSchemaFromDoc( // (`String`, `Int`, etc). However, if we pass a second param (extending an // existing schema) we do! So, we should find a way to validate that we don't // shadow builtins. - const validationErrors = validateSDL(doc); - if (validationErrors.length > 0) { - return err(validationErrors.map(graphQlErrorToDiagnostic)); - } + // const validationErrors = validateSDL(doc); + // if (validationErrors.length > 0) { + // return err(validationErrors.map(graphQlErrorToDiagnostic)); + // } const schema = buildASTSchema(doc, { assumeValidSDL: true }); - const diagnostics = validateSchema(schema) - // FIXME: Handle case where query is not defined (no location) - .filter((e) => e.source && e.locations && e.positions); - - if (diagnostics.length > 0) { - return err(diagnostics.map(graphQlErrorToDiagnostic)); - } + // const diagnostics = validateSchema(schema) + // FIXME: Handle case where query is not defined (no location) + // .filter((e) => e.source && e.locations && e.positions); + // + // if (diagnostics.length > 0) { + // return err(diagnostics.map(graphQlErrorToDiagnostic)); + // } return ok(schema); } diff --git a/src/tests/TestRunner.ts b/src/tests/TestRunner.ts index 7cc519cf..56202b66 100644 --- a/src/tests/TestRunner.ts +++ b/src/tests/TestRunner.ts @@ -36,10 +36,12 @@ export default class TestRunner { const filterRegex = filter != null ? new RegExp(filter) : null; for (const fileName of readdirSyncRecursive(fixturesDir)) { if (testFilePattern.test(fileName)) { - this._testFixtures.push(fileName); - const filePath = path.join(fixturesDir, fileName); - if (filterRegex != null && !filePath.match(filterRegex)) { - this._skip.add(fileName); + if (!(ignoreFilePattern && ignoreFilePattern.test(fileName))) { + this._testFixtures.push(fileName); + const filePath = path.join(fixturesDir, fileName); + if (filterRegex != null && !filePath.match(filterRegex)) { + this._skip.add(fileName); + } } } else if (!ignoreFilePattern || !ignoreFilePattern.test(fileName)) { this._otherFiles.add(fileName); diff --git a/src/tests/fixtures/externals/nonGratsPackage.ignore.ts b/src/tests/fixtures/externals/nonGratsPackage.ignore.ts new file mode 100644 index 00000000..f3fe7e36 --- /dev/null +++ b/src/tests/fixtures/externals/nonGratsPackage.ignore.ts @@ -0,0 +1,7 @@ +export type SomeType = { + id: string; +}; + +export type SomeInterface = { + id: string; +}; diff --git a/src/tests/fixtures/externals/nonGratsPackageWrapper.ts b/src/tests/fixtures/externals/nonGratsPackageWrapper.ts new file mode 100644 index 00000000..e19a8d5c --- /dev/null +++ b/src/tests/fixtures/externals/nonGratsPackageWrapper.ts @@ -0,0 +1,19 @@ +// { "EXPERIMENTAL__emitResolverMap": true } + +import { + SomeType as _SomeType, + SomeInterface as _SomeInterface, +} from "./nonGratsPackage.ignore"; + +/** + * @gqlType MyType + * @gqlExternal "./test-sdl.graphql" + */ +export type SomeType = _SomeType; + +/** + * @gqlField + */ +export function someField(parent: SomeType): string { + return parent.id; +} diff --git a/src/tests/fixtures/externals/nonGratsPackageWrapper.ts.expected b/src/tests/fixtures/externals/nonGratsPackageWrapper.ts.expected new file mode 100644 index 00000000..e69de29b diff --git a/src/tests/fixtures/externals/test-sdl.graphql b/src/tests/fixtures/externals/test-sdl.graphql new file mode 100644 index 00000000..48c1d495 --- /dev/null +++ b/src/tests/fixtures/externals/test-sdl.graphql @@ -0,0 +1,7 @@ +type MyType { + id: ID! +} + +interface MyInterface { + id: ID! +} diff --git a/src/tests/test.ts b/src/tests/test.ts index cb762eed..fa71b39a 100644 --- a/src/tests/test.ts +++ b/src/tests/test.ts @@ -26,7 +26,11 @@ import { validateGratsOptions, } from "../gratsConfig"; import { SEMANTIC_NON_NULL_DIRECTIVE } from "../publicDirectives"; -import { applySDLHeader, applyTypeScriptHeader } from "../printSchema"; +import { + applySDLHeader, + applyTypeScriptHeader, + printExecutableSchema, +} from "../printSchema"; import { extend } from "../utils/helpers"; const TS_VERSION = ts.version; @@ -77,7 +81,7 @@ const testDirs = [ { fixturesDir, testFilePattern: /\.ts$/, - ignoreFilePattern: null, + ignoreFilePattern: /\.ignore\.ts$/, transformer: (code: string, fileName: string): string | false => { const firstLine = code.split("\n")[0]; let config: Partial = { @@ -136,14 +140,11 @@ const testDirs = [ const { schema, doc, resolvers } = schemaResult.value; // We run codegen here just ensure that it doesn't throw. - const executableSchema = applyTypeScriptHeader( + const executableSchema = printExecutableSchema( + schema, + resolvers, parsedOptions.raw.grats, - codegen( - schema, - resolvers, - parsedOptions.raw.grats, - `${fixturesDir}/${fileName}`, - ), + `${fixturesDir}/${fileName}`, ); const LOCATION_REGEX = /^\/\/ Locate: (.*)/; diff --git a/src/transforms/makeResolverSignature.ts b/src/transforms/makeResolverSignature.ts index a97e7507..9c81db08 100644 --- a/src/transforms/makeResolverSignature.ts +++ b/src/transforms/makeResolverSignature.ts @@ -14,7 +14,10 @@ export function makeResolverSignature(documentAst: DocumentNode): Metadata { }; for (const declaration of documentAst.definitions) { - if (declaration.kind !== Kind.OBJECT_TYPE_DEFINITION) { + if ( + declaration.kind !== Kind.OBJECT_TYPE_DEFINITION && + declaration.kind !== Kind.OBJECT_TYPE_EXTENSION + ) { continue; } if (declaration.fields == null) { diff --git a/src/transforms/mergeExtensions.ts b/src/transforms/mergeExtensions.ts index 48a4320f..8df657ba 100644 --- a/src/transforms/mergeExtensions.ts +++ b/src/transforms/mergeExtensions.ts @@ -1,11 +1,17 @@ import { DocumentNode, FieldDefinitionNode, visit } from "graphql"; import { extend } from "../utils/helpers"; +import { TypeContext } from "../TypeContext"; /** * Takes every example of `extend type Foo` and `extend interface Foo` and * merges them into the original type/interface definition. + * + * Do not merge the ones that are external */ -export function mergeExtensions(doc: DocumentNode): DocumentNode { +export function mergeExtensions( + ctx: TypeContext, + doc: DocumentNode, +): DocumentNode { const fields = new MultiMap(); // Collect all the fields from the extensions and trim them from the AST. @@ -14,8 +20,13 @@ export function mergeExtensions(doc: DocumentNode): DocumentNode { if (t.directives != null || t.interfaces != null) { throw new Error("Unexpected directives or interfaces on Extension"); } - fields.extend(t.name.value, t.fields); - return null; + const nameDef = ctx.getNameDefinition(t.name); + if (nameDef && nameDef.externalImportPath) { + return t; + } else { + fields.extend(t.name.value, t.fields); + return null; + } }, InterfaceTypeExtension(t) { if (t.directives != null || t.interfaces != null) { From 69bba951c648861a89d70f0717e5cc23dd4d4de9 Mon Sep 17 00:00:00 2001 From: Mikhail Novikov Date: Wed, 11 Dec 2024 17:53:48 +0100 Subject: [PATCH 02/11] Fix todo --- TODO.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/TODO.md b/TODO.md index 036d0049..487140b1 100644 --- a/TODO.md +++ b/TODO.md @@ -1,6 +1,8 @@ -- SDL should extend type for external types - I guess marking types in SDL -- all imported types -- Read SDL to actually do validation -- "modular" mode? like no full schema, but parts of schema but with full validation by resolving it? -- all tests to add fixtures for metadata/resolver map -- pluggable module resolution +- [x] SDL should extend type for external types - I guess marking types in SDL + - [ ] can't generate graphql-js stuff, don't want to do it for externs - don't support graphql-js for this? +- [ ] all imported types (so support interfaces etc) +- [ ] Read SDL to actually do validation + - [ ] reenable global validations +- [ ] "modular" mode? like no full schema, but parts of schema but with full validation by resolving it? +- [ ] all tests to add fixtures for metadata/resolver map +- [ ] pluggable module resolution - too many variables there, use filepath by default, let users customize it From a43159986b6ec923f64bf1678d5a6b07b1f332e0 Mon Sep 17 00:00:00 2001 From: Mikhail Novikov Date: Mon, 16 Dec 2024 11:29:32 +0100 Subject: [PATCH 03/11] Actually usable stuff --- TODO.md | 9 +-- src/Errors.ts | 4 ++ src/Extractor.ts | 23 +++++-- src/GraphQLAstExtensions.ts | 40 ++++++++++- src/lib.ts | 22 +++--- src/printSchema.ts | 54 ++++++++++++++- .../externals/nonGratsPackageWrapper.ts | 7 +- .../nonGratsPackageWrapper.ts.expected | 57 ++++++++++++++++ ...st-sdl.graphql => test-sdl.ignore.graphql} | 0 src/tests/fixtures/externals/variousErrors.ts | 30 +++++++++ .../externals/variousErrors.ts.expected | 49 ++++++++++++++ src/tests/test.ts | 33 ++------- src/transforms/addImportedSchemas.ts | 67 +++++++++++++++++++ src/transforms/makeResolverSignature.ts | 4 ++ src/transforms/snapshotsFromProgram.ts | 2 +- 15 files changed, 346 insertions(+), 55 deletions(-) rename src/tests/fixtures/externals/{test-sdl.graphql => test-sdl.ignore.graphql} (100%) create mode 100644 src/tests/fixtures/externals/variousErrors.ts create mode 100644 src/tests/fixtures/externals/variousErrors.ts.expected create mode 100644 src/transforms/addImportedSchemas.ts diff --git a/TODO.md b/TODO.md index 487140b1..66ce93f7 100644 --- a/TODO.md +++ b/TODO.md @@ -1,8 +1,9 @@ - [x] SDL should extend type for external types - I guess marking types in SDL - - [ ] can't generate graphql-js stuff, don't want to do it for externs - don't support graphql-js for this? + - [x] can't generate graphql-js stuff, don't want to do it for externs - don't support graphql-js for this? - [ ] all imported types (so support interfaces etc) -- [ ] Read SDL to actually do validation - - [ ] reenable global validations -- [ ] "modular" mode? like no full schema, but parts of schema but with full validation by resolving it? +- [x] Read SDL to actually do validation + - [x] reenable global validations +- [x] "modular" mode? like no full schema, but parts of schema but with full validation by resolving it? + - [?] treat query/mutation/subscription as "import" type and extend it - [ ] all tests to add fixtures for metadata/resolver map - [ ] pluggable module resolution - too many variables there, use filepath by default, let users customize it diff --git a/src/Errors.ts b/src/Errors.ts index 571f5b71..1b976f98 100644 --- a/src/Errors.ts +++ b/src/Errors.ts @@ -611,3 +611,7 @@ export function tsConfigNotFound(cwd: string) { export function noModuleInGqlExternal() { return `Grats: @gqlExternal must include a module name in double quotes. For example: /** @gqlExternal "myModule" */`; } + +export function graphqlExternalNotInResolverMapMode() { + return `Grats: @gqlExternal can only be used if grats is in EXPERIMENTAL__emitResolverMap mode. */`; +} diff --git a/src/Extractor.ts b/src/Extractor.ts index 85347476..0bb61267 100644 --- a/src/Extractor.ts +++ b/src/Extractor.ts @@ -40,6 +40,8 @@ import { InputValueDefinitionNodeOrResolverArg, ResolverArgument, } from "./resolverSignature"; +import path = require("path"); +import { GratsConfig } from "./gratsConfig"; export const LIBRARY_IMPORT_NAME = "grats"; export const LIBRARY_NAME = "Grats"; @@ -109,8 +111,9 @@ type FieldTypeContext = { */ export function extract( sourceFile: ts.SourceFile, + options: GratsConfig, ): DiagnosticsResult { - const extractor = new Extractor(); + const extractor = new Extractor(options); return extractor.extract(sourceFile); } @@ -126,7 +129,7 @@ class Extractor { errors: ts.DiagnosticWithLocation[] = []; gql: GraphQLConstructor; - constructor() { + constructor(private _options: GratsConfig) { this.gql = new GraphQLConstructor(); } @@ -235,6 +238,9 @@ class Extractor { break; } case EXTERNAL_TAG: + if (!this._options.EXPERIMENTAL__emitResolverMap) { + this.report(tag.tagName, E.graphqlExternalNotInResolverMapMode()); + } if (!this.hasTag(node, TYPE_TAG)) { this.report(tag.tagName, E.specifiedByOnWrongNode()); } @@ -1085,7 +1091,13 @@ class Extractor { this.hasTag(node, EXTERNAL_TAG) ) { const externalTag = this.findTag(node, EXTERNAL_TAG) as ts.JSDocTag; - externalImportPath = this.externalModule(node, externalTag); + const externalPathMaybe = this.externalModule(node, externalTag); + if (externalPathMaybe) { + externalImportPath = path.resolve( + path.dirname(node.getSourceFile().fileName), + externalPathMaybe, + ); + } console.log("DEBUG - External import path", externalImportPath); } else { return this.report(node.type, E.typeTagOnAliasOfNonObjectOrUnknown()); @@ -1883,8 +1895,9 @@ class Extractor { const commentText = ts.getTextOfJSDocComment(tag.comment); if (commentText) { const match = commentText.match(/^\s*"(.*)"\s*$/); - if (match && match[0]) { - externalModule = match[0]; + + if (match && match[1]) { + externalModule = match[1]; } } } diff --git a/src/GraphQLAstExtensions.ts b/src/GraphQLAstExtensions.ts index 19250843..ada4b397 100644 --- a/src/GraphQLAstExtensions.ts +++ b/src/GraphQLAstExtensions.ts @@ -37,6 +37,10 @@ declare module "graphql" { tsModulePath: string; exportName: string | null; }; + /** + * Grats metadata: Indicates whether this definition was imported from external module + */ + isExternalType?: boolean; } export interface UnionTypeDefinitionNode { @@ -45,6 +49,11 @@ declare module "graphql" { * generic type resolution. */ wasSynthesized?: boolean; + + /** + * Grats metadata: Indicates whether this definition was imported from external module + */ + isExternalType?: boolean; } export interface InterfaceTypeDefinitionNode { /** @@ -52,6 +61,11 @@ declare module "graphql" { * generic type resolution. */ wasSynthesized?: boolean; + + /** + * Grats metadata: Indicates whether this definition was imported from external module + */ + isExternalType?: boolean; } export interface ObjectTypeExtensionNode { /** @@ -59,10 +73,11 @@ declare module "graphql" { * or a type. */ mayBeInterface?: boolean; + /** - * Grats metadata: Indicates whether this extension is for external type + * Grats metadata: Indicates whether this definition was imported from external module */ - isOnExternalType?: boolean; + isExternalType?: boolean; } export interface FieldDefinitionNode { @@ -75,4 +90,25 @@ declare module "graphql" { resolver?: ResolverSignature; killsParentOnException?: NameNode; } + + export interface ScalarTypeDefinitionNode { + /** + * Grats metadata: Indicates whether this definition was imported from external module + */ + isExternalType?: boolean; + } + + export interface EnumTypeDefinitionNode { + /** + * Grats metadata: Indicates whether this definition was imported from external module + */ + isExternalType?: boolean; + } + + export interface InputObjectTypeDefinitionNode { + /** + * Grats metadata: Indicates whether this definition was imported from external module + */ + isExternalType?: boolean; + } } diff --git a/src/lib.ts b/src/lib.ts index 71b20ad1..932cffcc 100644 --- a/src/lib.ts +++ b/src/lib.ts @@ -34,6 +34,7 @@ import { resolveResolverParams } from "./transforms/resolveResolverParams"; import { customSpecValidations } from "./validations/customSpecValidations"; import { makeResolverSignature } from "./transforms/makeResolverSignature"; import { addImplicitRootTypes } from "./transforms/addImplicitRootTypes"; +import { mergeImportedSchemas } from "./transforms/addImportedSchemas"; import { Metadata } from "./metadata"; // Export the TypeScript plugin implementation used by @@ -119,6 +120,7 @@ export function extractSchemaAndDoc( .map((doc) => addImplicitRootTypes(doc)) // Merge any `extend` definitions into their base definitions. .map((doc) => mergeExtensions(ctx, doc)) + .andThen((doc) => mergeImportedSchemas(ctx, doc)) // Perform custom validations that reimplement spec validation rules // with more tailored error messages. // TODO @@ -156,19 +158,19 @@ function buildSchemaFromDoc( // (`String`, `Int`, etc). However, if we pass a second param (extending an // existing schema) we do! So, we should find a way to validate that we don't // shadow builtins. - // const validationErrors = validateSDL(doc); - // if (validationErrors.length > 0) { - // return err(validationErrors.map(graphQlErrorToDiagnostic)); - // } + const validationErrors = validateSDL(doc); + if (validationErrors.length > 0) { + return err(validationErrors.map(graphQlErrorToDiagnostic)); + } const schema = buildASTSchema(doc, { assumeValidSDL: true }); - // const diagnostics = validateSchema(schema) - // FIXME: Handle case where query is not defined (no location) - // .filter((e) => e.source && e.locations && e.positions); + const diagnostics = validateSchema(schema) + // FIXME: Handle case where query is not defined (no location) + .filter((e) => e.source && e.locations && e.positions); // - // if (diagnostics.length > 0) { - // return err(diagnostics.map(graphQlErrorToDiagnostic)); - // } + if (diagnostics.length > 0) { + return err(diagnostics.map(graphQlErrorToDiagnostic)); + } return ok(schema); } diff --git a/src/printSchema.ts b/src/printSchema.ts index 186576dc..facef5d2 100644 --- a/src/printSchema.ts +++ b/src/printSchema.ts @@ -49,9 +49,57 @@ export function applySDLHeader(config: GratsConfig, sdl: string): string { export function printSDLWithoutMetadata(doc: DocumentNode): string { const trimmed = visit(doc, { ScalarTypeDefinition(t) { - return specifiedScalarTypes.some((scalar) => scalar.name === t.name.value) - ? null - : t; + if (t.isExternalType) { + return null; + } else if ( + specifiedScalarTypes.some((scalar) => scalar.name === t.name.value) + ) { + return null; + } else { + return t; + } + }, + ObjectTypeDefinition(t) { + if (t.isExternalType) { + return null; + } else { + return t; + } + }, + InterfaceTypeDefinition(t) { + if (t.isExternalType) { + return null; + } else { + return t; + } + }, + ObjectTypeExtension(t) { + if (t.isExternalType) { + return null; + } else { + return t; + } + }, + UnionTypeDefinition(t) { + if (t.isExternalType) { + return null; + } else { + return t; + } + }, + EnumTypeDefinition(t) { + if (t.isExternalType) { + return null; + } else { + return t; + } + }, + InputObjectTypeDefinition(t) { + if (t.isExternalType) { + return null; + } else { + return t; + } }, }); return print(trimmed); diff --git a/src/tests/fixtures/externals/nonGratsPackageWrapper.ts b/src/tests/fixtures/externals/nonGratsPackageWrapper.ts index e19a8d5c..dd6246f6 100644 --- a/src/tests/fixtures/externals/nonGratsPackageWrapper.ts +++ b/src/tests/fixtures/externals/nonGratsPackageWrapper.ts @@ -7,7 +7,7 @@ import { /** * @gqlType MyType - * @gqlExternal "./test-sdl.graphql" + * @gqlExternal "./test-sdl.ignore.graphql" */ export type SomeType = _SomeType; @@ -17,3 +17,8 @@ export type SomeType = _SomeType; export function someField(parent: SomeType): string { return parent.id; } + +/** @gqlQueryField */ +export function myRoot(): SomeType | null { + return null; +} diff --git a/src/tests/fixtures/externals/nonGratsPackageWrapper.ts.expected b/src/tests/fixtures/externals/nonGratsPackageWrapper.ts.expected index e69de29b..5c701b7c 100644 --- a/src/tests/fixtures/externals/nonGratsPackageWrapper.ts.expected +++ b/src/tests/fixtures/externals/nonGratsPackageWrapper.ts.expected @@ -0,0 +1,57 @@ +----------------- +INPUT +----------------- +// { "EXPERIMENTAL__emitResolverMap": true } + +import { + SomeType as _SomeType, + SomeInterface as _SomeInterface, +} from "./nonGratsPackage.ignore"; + +/** + * @gqlType MyType + * @gqlExternal "./test-sdl.ignore.graphql" + */ +export type SomeType = _SomeType; + +/** + * @gqlField + */ +export function someField(parent: SomeType): string { + return parent.id; +} + +/** @gqlQueryField */ +export function myRoot(): SomeType | null { + return null; +} + +----------------- +OUTPUT +----------------- +-- SDL -- +type Query { + myRoot: MyType +} + +extend type MyType { + someField: String +} + +-- TypeScript -- +import { IResolvers } from "@graphql-tools/utils"; +import { myRoot as queryMyRootResolver, someField as myTypeSomeFieldResolver } from "./nonGratsPackageWrapper"; +export function getResolverMap(): IResolvers { + return { + Query: { + myRoot() { + return queryMyRootResolver(); + } + }, + MyType: { + someField(source) { + return myTypeSomeFieldResolver(source); + } + } + }; +} diff --git a/src/tests/fixtures/externals/test-sdl.graphql b/src/tests/fixtures/externals/test-sdl.ignore.graphql similarity index 100% rename from src/tests/fixtures/externals/test-sdl.graphql rename to src/tests/fixtures/externals/test-sdl.ignore.graphql diff --git a/src/tests/fixtures/externals/variousErrors.ts b/src/tests/fixtures/externals/variousErrors.ts new file mode 100644 index 00000000..bf4c2186 --- /dev/null +++ b/src/tests/fixtures/externals/variousErrors.ts @@ -0,0 +1,30 @@ +// { "EXPERIMENTAL__emitResolverMap": false } + +import { + SomeType as _SomeType, + SomeInterface as _SomeInterface, +} from "./nonGratsPackage.ignore"; + +/** + * @gqlType MyType + * @gqlExternal "./test-sdl.ignore.graphql" + */ +export type SomeType = _SomeType; + +/** + * @gqlType MyType + * @gqlExternal + */ +export type OtherType = _SomeType; + +/** + * @gqlField + */ +export function someField(parent: SomeType): string { + return parent.id; +} + +/** @gqlQueryField */ +export function myRoot(): SomeType | null { + return null; +} diff --git a/src/tests/fixtures/externals/variousErrors.ts.expected b/src/tests/fixtures/externals/variousErrors.ts.expected new file mode 100644 index 00000000..03166443 --- /dev/null +++ b/src/tests/fixtures/externals/variousErrors.ts.expected @@ -0,0 +1,49 @@ +----------------- +INPUT +----------------- +// { "EXPERIMENTAL__emitResolverMap": false } + +import { + SomeType as _SomeType, + SomeInterface as _SomeInterface, +} from "./nonGratsPackage.ignore"; + +/** + * @gqlType MyType + * @gqlExternal "./test-sdl.ignore.graphql" + */ +export type SomeType = _SomeType; + +/** + * @gqlType MyType + * @gqlExternal + */ +export type OtherType = _SomeType; + +/** + * @gqlField + */ +export function someField(parent: SomeType): string { + return parent.id; +} + +/** @gqlQueryField */ +export function myRoot(): SomeType | null { + return null; +} + +----------------- +OUTPUT +----------------- +src/tests/fixtures/externals/variousErrors.ts:10:5 - error: Grats: @gqlExternal can only be used if grats is in EXPERIMENTAL__emitResolverMap mode. */ + +10 * @gqlExternal "./test-sdl.ignore.graphql" + ~~~~~~~~~~~ +src/tests/fixtures/externals/variousErrors.ts:18:1 - error: Grats: @gqlExternal must include a module name in double quotes. For example: /** @gqlExternal "myModule" */ + +18 export type OtherType = _SomeType; + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +src/tests/fixtures/externals/variousErrors.ts:16:5 - error: Grats: @gqlExternal can only be used if grats is in EXPERIMENTAL__emitResolverMap mode. */ + +16 * @gqlExternal + ~~~~~~~~~~~ diff --git a/src/tests/test.ts b/src/tests/test.ts index fa71b39a..1a85bf22 100644 --- a/src/tests/test.ts +++ b/src/tests/test.ts @@ -5,14 +5,7 @@ import { buildSchemaAndDocResultWithHost, } from "../lib"; import * as ts from "typescript"; -import { - buildASTSchema, - graphql, - GraphQLSchema, - print, - printSchema, - specifiedScalarTypes, -} from "graphql"; +import { buildASTSchema, graphql, GraphQLSchema, printSchema } from "graphql"; import { Command } from "commander"; import { locate } from "../Locate"; import { gqlErr, ReportableDiagnostics } from "../utils/DiagnosticError"; @@ -26,11 +19,7 @@ import { validateGratsOptions, } from "../gratsConfig"; import { SEMANTIC_NON_NULL_DIRECTIVE } from "../publicDirectives"; -import { - applySDLHeader, - applyTypeScriptHeader, - printExecutableSchema, -} from "../printSchema"; +import { printExecutableSchema, printGratsSDL } from "../printSchema"; import { extend } from "../utils/helpers"; const TS_VERSION = ts.version; @@ -81,7 +70,7 @@ const testDirs = [ { fixturesDir, testFilePattern: /\.ts$/, - ignoreFilePattern: /\.ignore\.ts$/, + ignoreFilePattern: /\.ignore\.(ts|graphql)$/, transformer: (code: string, fileName: string): string | false => { const firstLine = code.split("\n")[0]; let config: Partial = { @@ -159,21 +148,7 @@ const testDirs = [ gqlErr({ loc: locResult.value }, "Located here"), ]).formatDiagnosticsWithContext(); } else { - const docSansDirectives = { - ...doc, - definitions: doc.definitions.filter((def) => { - if (def.kind === "ScalarTypeDefinition") { - return !specifiedScalarTypes.some( - (scalar) => scalar.name === def.name.value, - ); - } - return true; - }), - }; - const sdl = applySDLHeader( - parsedOptions.raw.grats, - print(docSansDirectives), - ); + const sdl = printGratsSDL(doc, parsedOptions.raw.grats); return `-- SDL --\n${sdl}\n-- TypeScript --\n${executableSchema}`; } diff --git a/src/transforms/addImportedSchemas.ts b/src/transforms/addImportedSchemas.ts new file mode 100644 index 00000000..3350121d --- /dev/null +++ b/src/transforms/addImportedSchemas.ts @@ -0,0 +1,67 @@ +import * as path from "path"; +import * as fs from "fs"; +import * as ts from "typescript"; +import { + DefinitionNode, + DocumentNode, + GraphQLError, + Kind, + parse, +} from "graphql"; +import { TypeContext } from "../TypeContext"; +import { + DiagnosticsWithoutLocationResult, + graphQlErrorToDiagnostic, +} from "../utils/DiagnosticError"; +import { err, ok } from "../utils/Result"; + +export function mergeImportedSchemas( + ctx: TypeContext, + doc: DocumentNode, +): DiagnosticsWithoutLocationResult { + const importedSchemas: Set = new Set(); + for (const name of ctx.allNameDefinitions()) { + if (name.externalImportPath) { + importedSchemas.add(name.externalImportPath); + } + } + const importedDefinitions: DefinitionNode[] = []; + const errors: ts.Diagnostic[] = []; + for (const schemaPath of importedSchemas) { + const text = fs.readFileSync(path.resolve(schemaPath), "utf-8"); + try { + const parsedAst = parse(text); + for (const def of parsedAst.definitions) { + if ( + def.kind === Kind.OPERATION_DEFINITION || + def.kind === Kind.FRAGMENT_DEFINITION || + def.kind === Kind.DIRECTIVE_DEFINITION || + def.kind === Kind.SCHEMA_DEFINITION || + def.kind === Kind.SCHEMA_EXTENSION || + def.kind === Kind.SCALAR_TYPE_EXTENSION || + def.kind === Kind.INTERFACE_TYPE_EXTENSION || + def.kind === Kind.INPUT_OBJECT_TYPE_EXTENSION || + def.kind === Kind.UNION_TYPE_EXTENSION || + def.kind === Kind.ENUM_TYPE_EXTENSION + ) { + continue; + } + def.isExternalType = true; + importedDefinitions.push(def); + } + } catch (e) { + if (e instanceof GraphQLError) { + errors.push(graphQlErrorToDiagnostic(e)); + } + } + } + if (errors.length > 0) { + console.log(errors); + return err(errors); + } + const newDoc: DocumentNode = { + ...doc, + definitions: [...doc.definitions, ...importedDefinitions], + }; + return ok(newDoc); +} diff --git a/src/transforms/makeResolverSignature.ts b/src/transforms/makeResolverSignature.ts index 9c81db08..a5d2afdc 100644 --- a/src/transforms/makeResolverSignature.ts +++ b/src/transforms/makeResolverSignature.ts @@ -24,6 +24,10 @@ export function makeResolverSignature(documentAst: DocumentNode): Metadata { continue; } + if (declaration.isExternalType) { + continue; + } + const fieldResolvers: Record = {}; for (const fieldAst of declaration.fields) { diff --git a/src/transforms/snapshotsFromProgram.ts b/src/transforms/snapshotsFromProgram.ts index 262eebe2..a5c6f85f 100644 --- a/src/transforms/snapshotsFromProgram.ts +++ b/src/transforms/snapshotsFromProgram.ts @@ -48,7 +48,7 @@ export function extractSnapshotsFromProgram( } const extractResults = gratsSourceFiles.map((sourceFile) => { - return extract(sourceFile); + return extract(sourceFile, options.raw.grats); }); return collectResults(extractResults); From d9acd95c534baf0185694f48030d754d92b83591 Mon Sep 17 00:00:00 2001 From: Mikhail Novikov Date: Mon, 16 Dec 2024 15:59:03 +0100 Subject: [PATCH 04/11] Support all types and add tests to them --- TODO.md | 5 +- src/Errors.ts | 14 +- src/Extractor.ts | 135 ++++++++++++++---- ...{variousErrors.ts => fundamentalErrors.ts} | 0 ...expected => fundamentalErrors.ts.expected} | 6 +- src/tests/fixtures/externals/inputTypes.ts | 22 +++ .../fixtures/externals/inputTypes.ts.expected | 50 +++++++ src/tests/fixtures/externals/interface.ts | 36 +++++ .../fixtures/externals/interface.ts.expected | 71 +++++++++ .../externals/nonGratsPackage.ignore.ts | 17 +++ ...onGratsPackageWrapper.ts => objectType.ts} | 5 +- ...per.ts.expected => objectType.ts.expected} | 7 +- src/tests/fixtures/externals/scalar.ts | 44 ++++++ .../fixtures/externals/scalar.ts.expected | 78 ++++++++++ .../externals/test-sdl.ignore.graphql | 18 +++ src/tests/fixtures/externals/union.ts | 17 +++ .../fixtures/externals/union.ts.expected | 41 ++++++ ...rfaceWithDeprecatedTag.invalid.ts.expected | 2 +- ...ypeImplementsInterface.invalid.ts.expected | 2 +- ...rfaceWithDeprecatedTag.invalid.ts.expected | 2 +- .../user_error/GqlTagDoesNotExist.ts.expected | 2 +- src/tests/test.ts | 2 +- src/validations/validateTypenames.ts | 6 +- 23 files changed, 535 insertions(+), 47 deletions(-) rename src/tests/fixtures/externals/{variousErrors.ts => fundamentalErrors.ts} (100%) rename src/tests/fixtures/externals/{variousErrors.ts.expected => fundamentalErrors.ts.expected} (61%) create mode 100644 src/tests/fixtures/externals/inputTypes.ts create mode 100644 src/tests/fixtures/externals/inputTypes.ts.expected create mode 100644 src/tests/fixtures/externals/interface.ts create mode 100644 src/tests/fixtures/externals/interface.ts.expected rename src/tests/fixtures/externals/{nonGratsPackageWrapper.ts => objectType.ts} (76%) rename src/tests/fixtures/externals/{nonGratsPackageWrapper.ts.expected => objectType.ts.expected} (86%) create mode 100644 src/tests/fixtures/externals/scalar.ts create mode 100644 src/tests/fixtures/externals/scalar.ts.expected create mode 100644 src/tests/fixtures/externals/union.ts create mode 100644 src/tests/fixtures/externals/union.ts.expected diff --git a/TODO.md b/TODO.md index 66ce93f7..5e55bea1 100644 --- a/TODO.md +++ b/TODO.md @@ -1,9 +1,12 @@ - [x] SDL should extend type for external types - I guess marking types in SDL - [x] can't generate graphql-js stuff, don't want to do it for externs - don't support graphql-js for this? -- [ ] all imported types (so support interfaces etc) +- [x] all imported types (so support interfaces etc) - [x] Read SDL to actually do validation - [x] reenable global validations - [x] "modular" mode? like no full schema, but parts of schema but with full validation by resolving it? - [?] treat query/mutation/subscription as "import" type and extend it - [ ] all tests to add fixtures for metadata/resolver map - [ ] pluggable module resolution - too many variables there, use filepath by default, let users customize it + - [ ] first try ts project resolution +- [ ] how to handle overimporting? Improting whole SDL module "infects" the schema with types that might not be requested. +- [ ] another check on error handling - I think eg enums and scalars accept stuff they shouldn't accept? diff --git a/src/Errors.ts b/src/Errors.ts index 1b976f98..65e502ac 100644 --- a/src/Errors.ts +++ b/src/Errors.ts @@ -13,6 +13,8 @@ import { SPECIFIED_BY_TAG, CONTEXT_TAG, INFO_TAG, + EXTERNAL_TAG, + AllTags, } from "./Extractor"; export const ISSUE_URL = "https://github.com/captbaritone/grats/issues"; @@ -153,6 +155,10 @@ export function typeTagOnAliasOfNonObjectOrUnknown() { return `Expected \`@${TYPE_TAG}\` type to be an object type literal (\`{ }\`) or \`unknown\`. For example: \`type Foo = { bar: string }\` or \`type Query = unknown\`.`; } +export function nonExternalTypeAlias(tag: AllTags) { + return `Expected \`@${tag}\` to be a type alias only if used with \`@${EXTERNAL_TAG}\``; +} + // TODO: Add code action export function typeNameNotDeclaration() { return `Expected \`__typename\` to be a property declaration. For example: \`__typename: "MyType"\`.`; @@ -612,6 +618,12 @@ export function noModuleInGqlExternal() { return `Grats: @gqlExternal must include a module name in double quotes. For example: /** @gqlExternal "myModule" */`; } -export function graphqlExternalNotInResolverMapMode() { +export function externalNotInResolverMapMode() { return `Grats: @gqlExternal can only be used if grats is in EXPERIMENTAL__emitResolverMap mode. */`; } + +export function externalOnWrongNode(extistingTags: string[]) { + return `Unexpected \`@${EXTERNAL_TAG}\` on type with following tags: ${extistingTags.join( + ", ", + )}. \`@${EXTERNAL_TAG}\` can only be used on type declarations.`; +} diff --git a/src/Extractor.ts b/src/Extractor.ts index 0bb61267..dbe7ec6b 100644 --- a/src/Extractor.ts +++ b/src/Extractor.ts @@ -66,6 +66,16 @@ export const KILLS_PARENT_ON_EXCEPTION_TAG = "killsParentOnException"; export const EXTERNAL_TAG = "gqlExternal"; +export type AllTags = + | typeof TYPE_TAG + | typeof FIELD_TAG + | typeof SCALAR_TAG + | typeof INTERFACE_TAG + | typeof ENUM_TAG + | typeof UNION_TAG + | typeof INPUT_TAG + | typeof EXTERNAL_TAG; + // All the tags that start with gql export const ALL_TAGS = [ TYPE_TAG, @@ -239,10 +249,22 @@ class Extractor { } case EXTERNAL_TAG: if (!this._options.EXPERIMENTAL__emitResolverMap) { - this.report(tag.tagName, E.graphqlExternalNotInResolverMapMode()); + this.report(tag.tagName, E.externalNotInResolverMapMode()); } - if (!this.hasTag(node, TYPE_TAG)) { - this.report(tag.tagName, E.specifiedByOnWrongNode()); + if ( + !this.hasTag(node, TYPE_TAG) && + !this.hasTag(node, INPUT_TAG) && + !this.hasTag(node, INTERFACE_TAG) + ) { + this.report( + tag.tagName, + E.externalOnWrongNode( + ts + .getJSDocTags(node) + .filter((t) => t.tagName.text !== EXTERNAL_TAG) + .map((t) => t.tagName.escapedText.toString()), + ), + ); } break; @@ -368,6 +390,8 @@ class Extractor { extractInterface(node: ts.Node, tag: ts.JSDocTag) { if (ts.isInterfaceDeclaration(node)) { this.interfaceInterfaceDeclaration(node, tag); + } else if (ts.isTypeAliasDeclaration(node)) { + this.interfaceTypeAliasDeclaration(node, tag); } else { this.report(tag, E.invalidInterfaceTagUsage()); } @@ -441,7 +465,16 @@ class Extractor { const name = this.entityName(node, tag); if (name == null) return null; - if (!ts.isUnionTypeNode(node.type)) { + if (ts.isTypeReferenceNode(node)) { + if (this.hasTag(node, EXTERNAL_TAG)) { + const externalImportPath = this.externalModule(node); + if (externalImportPath) { + this.recordTypeName(node, name, "UNION", externalImportPath); + return; + } + } + return this.report(node, E.nonExternalTypeAlias(UNION_TAG)); + } else if (!ts.isUnionTypeNode(node.type)) { return this.report(node, E.expectedUnionTypeNode()); } @@ -757,6 +790,15 @@ class Extractor { if (name == null) return null; const description = this.collectDescription(node); + + if (this.hasTag(node, EXTERNAL_TAG)) { + const externalImportPath = this.externalModule(node); + if (externalImportPath) { + this.recordTypeName(node, name, "SCALAR", externalImportPath); + return; + } + } + this.recordTypeName(node, name, "SCALAR"); // TODO: Can a scalar be deprecated? @@ -778,21 +820,30 @@ class Extractor { if (name == null) return null; const description = this.collectDescription(node); - this.recordTypeName(node, name, "INPUT_OBJECT"); - const fields = this.collectInputFields(node); + let externalImportPath: string | null = null; + if ( + node.type.kind === ts.SyntaxKind.TypeReference && + this.hasTag(node, EXTERNAL_TAG) + ) { + externalImportPath = this.externalModule(node); + } else { + const fields = this.collectInputFields(node); - const deprecatedDirective = this.collectDeprecated(node); + const deprecatedDirective = this.collectDeprecated(node); - this.definitions.push( - this.gql.inputObjectTypeDefinition( - node, - name, - fields, - deprecatedDirective == null ? null : [deprecatedDirective], - description, - ), - ); + this.definitions.push( + this.gql.inputObjectTypeDefinition( + node, + name, + fields, + deprecatedDirective == null ? null : [deprecatedDirective], + description, + ), + ); + } + + this.recordTypeName(node, name, "INPUT_OBJECT", externalImportPath); } inputInterfaceDeclaration(node: ts.InterfaceDeclaration, tag: ts.JSDocTag) { @@ -1090,15 +1141,7 @@ class Extractor { node.type.kind === ts.SyntaxKind.TypeReference && this.hasTag(node, EXTERNAL_TAG) ) { - const externalTag = this.findTag(node, EXTERNAL_TAG) as ts.JSDocTag; - const externalPathMaybe = this.externalModule(node, externalTag); - if (externalPathMaybe) { - externalImportPath = path.resolve( - path.dirname(node.getSourceFile().fileName), - externalPathMaybe, - ); - } - console.log("DEBUG - External import path", externalImportPath); + externalImportPath = this.externalModule(node); } else { return this.report(node.type, E.typeTagOnAliasOfNonObjectOrUnknown()); } @@ -1402,6 +1445,28 @@ class Extractor { ); } + interfaceTypeAliasDeclaration( + node: ts.TypeAliasDeclaration, + tag: ts.JSDocTag, + ) { + const name = this.entityName(node, tag); + if (name == null || name.value == null) { + return; + } + + if ( + node.type.kind === ts.SyntaxKind.TypeReference && + this.hasTag(node, EXTERNAL_TAG) + ) { + const externalImportPath = this.externalModule(node); + if (externalImportPath) { + this.recordTypeName(node, name, "INTERFACE", externalImportPath); + return; + } + } + return this.report(node.type, E.nonExternalTypeAlias(INTERFACE_TAG)); + } + collectFields( members: ReadonlyArray, ): Array { @@ -1724,6 +1789,14 @@ class Extractor { return; } + if (this.hasTag(node, EXTERNAL_TAG)) { + const externalImportPath = this.externalModule(node); + if (externalImportPath) { + this.recordTypeName(node, name, "ENUM", externalImportPath); + return; + } + } + const values = this.enumTypeAliasVariants(node); if (values == null) return; @@ -1889,7 +1962,12 @@ class Extractor { return this.gql.name(id, id.text); } - externalModule(node: ts.Node, tag: ts.JSDocTag) { + externalModule(node: ts.Node): string | null { + const tag = this.findTag(node, EXTERNAL_TAG); + if (!tag) { + return this.report(node, E.noModuleInGqlExternal()); + } + let externalModule; if (tag.comment != null) { const commentText = ts.getTextOfJSDocComment(tag.comment); @@ -1904,7 +1982,10 @@ class Extractor { if (!externalModule) { return this.report(node, E.noModuleInGqlExternal()); } - return externalModule; + return path.resolve( + path.dirname(node.getSourceFile().fileName), + externalModule, + ); } methodDeclaration( diff --git a/src/tests/fixtures/externals/variousErrors.ts b/src/tests/fixtures/externals/fundamentalErrors.ts similarity index 100% rename from src/tests/fixtures/externals/variousErrors.ts rename to src/tests/fixtures/externals/fundamentalErrors.ts diff --git a/src/tests/fixtures/externals/variousErrors.ts.expected b/src/tests/fixtures/externals/fundamentalErrors.ts.expected similarity index 61% rename from src/tests/fixtures/externals/variousErrors.ts.expected rename to src/tests/fixtures/externals/fundamentalErrors.ts.expected index 03166443..378936fa 100644 --- a/src/tests/fixtures/externals/variousErrors.ts.expected +++ b/src/tests/fixtures/externals/fundamentalErrors.ts.expected @@ -35,15 +35,15 @@ export function myRoot(): SomeType | null { ----------------- OUTPUT ----------------- -src/tests/fixtures/externals/variousErrors.ts:10:5 - error: Grats: @gqlExternal can only be used if grats is in EXPERIMENTAL__emitResolverMap mode. */ +src/tests/fixtures/externals/fundamentalErrors.ts:10:5 - error: Grats: @gqlExternal can only be used if grats is in EXPERIMENTAL__emitResolverMap mode. */ 10 * @gqlExternal "./test-sdl.ignore.graphql" ~~~~~~~~~~~ -src/tests/fixtures/externals/variousErrors.ts:18:1 - error: Grats: @gqlExternal must include a module name in double quotes. For example: /** @gqlExternal "myModule" */ +src/tests/fixtures/externals/fundamentalErrors.ts:18:1 - error: Grats: @gqlExternal must include a module name in double quotes. For example: /** @gqlExternal "myModule" */ 18 export type OtherType = _SomeType; ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -src/tests/fixtures/externals/variousErrors.ts:16:5 - error: Grats: @gqlExternal can only be used if grats is in EXPERIMENTAL__emitResolverMap mode. */ +src/tests/fixtures/externals/fundamentalErrors.ts:16:5 - error: Grats: @gqlExternal can only be used if grats is in EXPERIMENTAL__emitResolverMap mode. */ 16 * @gqlExternal ~~~~~~~~~~~ diff --git a/src/tests/fixtures/externals/inputTypes.ts b/src/tests/fixtures/externals/inputTypes.ts new file mode 100644 index 00000000..7e94f653 --- /dev/null +++ b/src/tests/fixtures/externals/inputTypes.ts @@ -0,0 +1,22 @@ +// { "EXPERIMENTAL__emitResolverMap": true } + +import { Int } from "../../../Types"; +import { SomeInputType as _SomeInputType } from "./nonGratsPackage.ignore"; + +/** + * @gqlInput MyInput + * @gqlExternal "./test-sdl.ignore.graphql" + */ +type MyInputType = _SomeInputType; + +/** + * @gqlInput + */ +type NestedInput = { + my: MyInputType; +}; + +/** @gqlQueryField */ +export function myRoot(my: MyInputType, nested: NestedInput): Int | null { + return null; +} diff --git a/src/tests/fixtures/externals/inputTypes.ts.expected b/src/tests/fixtures/externals/inputTypes.ts.expected new file mode 100644 index 00000000..b7b7d738 --- /dev/null +++ b/src/tests/fixtures/externals/inputTypes.ts.expected @@ -0,0 +1,50 @@ +----------------- +INPUT +----------------- +// { "EXPERIMENTAL__emitResolverMap": true } + +import { Int } from "../../../Types"; +import { SomeInputType as _SomeInputType } from "./nonGratsPackage.ignore"; + +/** + * @gqlInput MyInput + * @gqlExternal "./test-sdl.ignore.graphql" + */ +type MyInputType = _SomeInputType; + +/** + * @gqlInput + */ +type NestedInput = { + my: MyInputType; +}; + +/** @gqlQueryField */ +export function myRoot(my: MyInputType, nested: NestedInput): Int | null { + return null; +} + +----------------- +OUTPUT +----------------- +-- SDL -- +input NestedInput { + my: MyInput! +} + +type Query { + myRoot(my: MyInput!, nested: NestedInput!): Int +} + +-- TypeScript -- +import { IResolvers } from "@graphql-tools/utils"; +import { myRoot as queryMyRootResolver } from "./inputTypes"; +export function getResolverMap(): IResolvers { + return { + Query: { + myRoot(_source, args) { + return queryMyRootResolver(args.my, args.nested); + } + } + }; +} diff --git a/src/tests/fixtures/externals/interface.ts b/src/tests/fixtures/externals/interface.ts new file mode 100644 index 00000000..15666f0c --- /dev/null +++ b/src/tests/fixtures/externals/interface.ts @@ -0,0 +1,36 @@ +// { "EXPERIMENTAL__emitResolverMap": true } + +import { ID } from "../../../Types"; +import { SomeInterface as _SomeInterface } from "./nonGratsPackage.ignore"; + +/** + * @gqlInterface MyInterface + * @gqlExternal "./test-sdl.ignore.graphql" + */ +export type SomeType = _SomeInterface; + +/** + * @gqlType + */ +interface ImplementingType extends SomeType { + __typename: "ImplementingType"; + /** + * @gqlField + * @killsParentOnException + */ + id: ID; + /** @gqlField */ + otherField: string; +} + +/** + * @gqlField + */ +export function someField(parent: ImplementingType): string { + return parent.id; +} + +/** @gqlQueryField */ +export function myRoot(): ImplementingType | null { + return null; +} diff --git a/src/tests/fixtures/externals/interface.ts.expected b/src/tests/fixtures/externals/interface.ts.expected new file mode 100644 index 00000000..1e328dff --- /dev/null +++ b/src/tests/fixtures/externals/interface.ts.expected @@ -0,0 +1,71 @@ +----------------- +INPUT +----------------- +// { "EXPERIMENTAL__emitResolverMap": true } + +import { ID } from "../../../Types"; +import { SomeInterface as _SomeInterface } from "./nonGratsPackage.ignore"; + +/** + * @gqlInterface MyInterface + * @gqlExternal "./test-sdl.ignore.graphql" + */ +export type SomeType = _SomeInterface; + +/** + * @gqlType + */ +interface ImplementingType extends SomeType { + __typename: "ImplementingType"; + /** + * @gqlField + * @killsParentOnException + */ + id: ID; + /** @gqlField */ + otherField: string; +} + +/** + * @gqlField + */ +export function someField(parent: ImplementingType): string { + return parent.id; +} + +/** @gqlQueryField */ +export function myRoot(): ImplementingType | null { + return null; +} + +----------------- +OUTPUT +----------------- +-- SDL -- +type ImplementingType implements MyInterface { + id: ID! + otherField: String + someField: String +} + +type Query { + myRoot: ImplementingType +} + +-- TypeScript -- +import { IResolvers } from "@graphql-tools/utils"; +import { someField as implementingTypeSomeFieldResolver, myRoot as queryMyRootResolver } from "./interface"; +export function getResolverMap(): IResolvers { + return { + ImplementingType: { + someField(source) { + return implementingTypeSomeFieldResolver(source); + } + }, + Query: { + myRoot() { + return queryMyRootResolver(); + } + } + }; +} diff --git a/src/tests/fixtures/externals/nonGratsPackage.ignore.ts b/src/tests/fixtures/externals/nonGratsPackage.ignore.ts index f3fe7e36..cbe6d31f 100644 --- a/src/tests/fixtures/externals/nonGratsPackage.ignore.ts +++ b/src/tests/fixtures/externals/nonGratsPackage.ignore.ts @@ -1,7 +1,24 @@ export type SomeType = { + __typename: "MyType"; + id: string; +}; + +export type SomeOtherType = { + __typename: "MyOtherType"; id: string; }; export type SomeInterface = { id: string; }; + +export type SomeInputType = { + foo: number; + id: string; +}; + +export type SomeUnion = SomeType | SomeOtherType; + +export type SomeEnum = "A" | "B"; + +export type SomeScalar = string; diff --git a/src/tests/fixtures/externals/nonGratsPackageWrapper.ts b/src/tests/fixtures/externals/objectType.ts similarity index 76% rename from src/tests/fixtures/externals/nonGratsPackageWrapper.ts rename to src/tests/fixtures/externals/objectType.ts index dd6246f6..b21b30ac 100644 --- a/src/tests/fixtures/externals/nonGratsPackageWrapper.ts +++ b/src/tests/fixtures/externals/objectType.ts @@ -1,9 +1,6 @@ // { "EXPERIMENTAL__emitResolverMap": true } -import { - SomeType as _SomeType, - SomeInterface as _SomeInterface, -} from "./nonGratsPackage.ignore"; +import { SomeType as _SomeType } from "./nonGratsPackage.ignore"; /** * @gqlType MyType diff --git a/src/tests/fixtures/externals/nonGratsPackageWrapper.ts.expected b/src/tests/fixtures/externals/objectType.ts.expected similarity index 86% rename from src/tests/fixtures/externals/nonGratsPackageWrapper.ts.expected rename to src/tests/fixtures/externals/objectType.ts.expected index 5c701b7c..cc0d6d1d 100644 --- a/src/tests/fixtures/externals/nonGratsPackageWrapper.ts.expected +++ b/src/tests/fixtures/externals/objectType.ts.expected @@ -3,10 +3,7 @@ INPUT ----------------- // { "EXPERIMENTAL__emitResolverMap": true } -import { - SomeType as _SomeType, - SomeInterface as _SomeInterface, -} from "./nonGratsPackage.ignore"; +import { SomeType as _SomeType } from "./nonGratsPackage.ignore"; /** * @gqlType MyType @@ -40,7 +37,7 @@ extend type MyType { -- TypeScript -- import { IResolvers } from "@graphql-tools/utils"; -import { myRoot as queryMyRootResolver, someField as myTypeSomeFieldResolver } from "./nonGratsPackageWrapper"; +import { myRoot as queryMyRootResolver, someField as myTypeSomeFieldResolver } from "./objectType"; export function getResolverMap(): IResolvers { return { Query: { diff --git a/src/tests/fixtures/externals/scalar.ts b/src/tests/fixtures/externals/scalar.ts new file mode 100644 index 00000000..c890584f --- /dev/null +++ b/src/tests/fixtures/externals/scalar.ts @@ -0,0 +1,44 @@ +// { "EXPERIMENTAL__emitResolverMap": true } + +import { + SomeScalar as _SomeScalar, + SomeEnum as _SomeEnum, +} from "./nonGratsPackage.ignore"; + +/** + * @gqlInput MyScalar + * @gqlExternal "./test-sdl.ignore.graphql" + */ +type SomeScalar = _SomeScalar; + +/** + * @gqlInput MyEnum + * @gqlExternal "./test-sdl.ignore.graphql" + */ +type SomeEnum = _SomeEnum; + +/** + * @gqlInput + */ +type NestedInput = { + my: SomeScalar; + enum: SomeEnum; +}; + +/** + * @gqlType + */ +type NestedObject = { + /** @gqlField */ + my: SomeScalar; + /** @gqlField */ + enum: SomeEnum; +}; + +/** @gqlQueryField */ +export function myRoot( + my: SomeScalar, + nested: NestedInput, +): NestedObject | null { + return null; +} diff --git a/src/tests/fixtures/externals/scalar.ts.expected b/src/tests/fixtures/externals/scalar.ts.expected new file mode 100644 index 00000000..cb556c7b --- /dev/null +++ b/src/tests/fixtures/externals/scalar.ts.expected @@ -0,0 +1,78 @@ +----------------- +INPUT +----------------- +// { "EXPERIMENTAL__emitResolverMap": true } + +import { + SomeScalar as _SomeScalar, + SomeEnum as _SomeEnum, +} from "./nonGratsPackage.ignore"; + +/** + * @gqlInput MyScalar + * @gqlExternal "./test-sdl.ignore.graphql" + */ +type SomeScalar = _SomeScalar; + +/** + * @gqlInput MyEnum + * @gqlExternal "./test-sdl.ignore.graphql" + */ +type SomeEnum = _SomeEnum; + +/** + * @gqlInput + */ +type NestedInput = { + my: SomeScalar; + enum: SomeEnum; +}; + +/** + * @gqlType + */ +type NestedObject = { + /** @gqlField */ + my: SomeScalar; + /** @gqlField */ + enum: SomeEnum; +}; + +/** @gqlQueryField */ +export function myRoot( + my: SomeScalar, + nested: NestedInput, +): NestedObject | null { + return null; +} + +----------------- +OUTPUT +----------------- +-- SDL -- +input NestedInput { + enum: MyEnum! + my: MyScalar! +} + +type NestedObject { + enum: MyEnum + my: MyScalar +} + +type Query { + myRoot(my: MyScalar!, nested: NestedInput!): NestedObject +} + +-- TypeScript -- +import { IResolvers } from "@graphql-tools/utils"; +import { myRoot as queryMyRootResolver } from "./scalar"; +export function getResolverMap(): IResolvers { + return { + Query: { + myRoot(_source, args) { + return queryMyRootResolver(args.my, args.nested); + } + } + }; +} diff --git a/src/tests/fixtures/externals/test-sdl.ignore.graphql b/src/tests/fixtures/externals/test-sdl.ignore.graphql index 48c1d495..77611a10 100644 --- a/src/tests/fixtures/externals/test-sdl.ignore.graphql +++ b/src/tests/fixtures/externals/test-sdl.ignore.graphql @@ -2,6 +2,24 @@ type MyType { id: ID! } +type MyOtherType { + id: ID! +} + interface MyInterface { id: ID! } + +input MyInput { + id: ID! + foo: Int! +} + +union MyUnion = MyType | MyOtherType + +enum MyEnum { + A + B +} + +scalar MyScalar diff --git a/src/tests/fixtures/externals/union.ts b/src/tests/fixtures/externals/union.ts new file mode 100644 index 00000000..45fbfa28 --- /dev/null +++ b/src/tests/fixtures/externals/union.ts @@ -0,0 +1,17 @@ +// { "EXPERIMENTAL__emitResolverMap": true } + +import { SomeUnion as _SomeUnion } from "./nonGratsPackage.ignore"; + +/** + * @gqlType MyUnion + * @gqlExternal "./test-sdl.ignore.graphql" + */ +export type SomeUnion = _SomeUnion; + +/** @gqlQueryField */ +export function myRoot(): SomeUnion { + return { + __typename: "MyType", + id: "foo", + }; +} diff --git a/src/tests/fixtures/externals/union.ts.expected b/src/tests/fixtures/externals/union.ts.expected new file mode 100644 index 00000000..c149a951 --- /dev/null +++ b/src/tests/fixtures/externals/union.ts.expected @@ -0,0 +1,41 @@ +----------------- +INPUT +----------------- +// { "EXPERIMENTAL__emitResolverMap": true } + +import { SomeUnion as _SomeUnion } from "./nonGratsPackage.ignore"; + +/** + * @gqlType MyUnion + * @gqlExternal "./test-sdl.ignore.graphql" + */ +export type SomeUnion = _SomeUnion; + +/** @gqlQueryField */ +export function myRoot(): SomeUnion { + return { + __typename: "MyType", + id: "foo", + }; +} + +----------------- +OUTPUT +----------------- +-- SDL -- +type Query { + myRoot: MyUnion +} + +-- TypeScript -- +import { IResolvers } from "@graphql-tools/utils"; +import { myRoot as queryMyRootResolver } from "./union"; +export function getResolverMap(): IResolvers { + return { + Query: { + myRoot() { + return queryMyRootResolver(); + } + } + }; +} diff --git a/src/tests/fixtures/type_definitions/TypeFromClassDefinitionImplementsInterfaceWithDeprecatedTag.invalid.ts.expected b/src/tests/fixtures/type_definitions/TypeFromClassDefinitionImplementsInterfaceWithDeprecatedTag.invalid.ts.expected index 171d6d61..36deafd6 100644 --- a/src/tests/fixtures/type_definitions/TypeFromClassDefinitionImplementsInterfaceWithDeprecatedTag.invalid.ts.expected +++ b/src/tests/fixtures/type_definitions/TypeFromClassDefinitionImplementsInterfaceWithDeprecatedTag.invalid.ts.expected @@ -26,7 +26,7 @@ src/tests/fixtures/type_definitions/TypeFromClassDefinitionImplementsInterfaceWi ~~~~~~~~~~~~~~~~~~~~~ 10 */ ~ -src/tests/fixtures/type_definitions/TypeFromClassDefinitionImplementsInterfaceWithDeprecatedTag.invalid.ts:9:5 - error: `@gqlImplements` is not a valid Grats tag. Valid tags are: `@gqlType`, `@gqlField`, `@gqlScalar`, `@gqlInterface`, `@gqlEnum`, `@gqlUnion`, `@gqlInput`. +src/tests/fixtures/type_definitions/TypeFromClassDefinitionImplementsInterfaceWithDeprecatedTag.invalid.ts:9:5 - error: `@gqlImplements` is not a valid Grats tag. Valid tags are: `@gqlType`, `@gqlField`, `@gqlScalar`, `@gqlInterface`, `@gqlEnum`, `@gqlUnion`, `@gqlInput`, `@gqlExternal`. 9 * @gqlImplements Person ~~~~~~~~~~~~~ diff --git a/src/tests/fixtures/type_definitions_from_alias/AliasTypeImplementsInterface.invalid.ts.expected b/src/tests/fixtures/type_definitions_from_alias/AliasTypeImplementsInterface.invalid.ts.expected index b317882b..af83753a 100644 --- a/src/tests/fixtures/type_definitions_from_alias/AliasTypeImplementsInterface.invalid.ts.expected +++ b/src/tests/fixtures/type_definitions_from_alias/AliasTypeImplementsInterface.invalid.ts.expected @@ -26,7 +26,7 @@ src/tests/fixtures/type_definitions_from_alias/AliasTypeImplementsInterface.inva ~~~~~~~~~~~~~~~~~~~~~ 4 */ ~ -src/tests/fixtures/type_definitions_from_alias/AliasTypeImplementsInterface.invalid.ts:3:5 - error: `@gqlImplements` is not a valid Grats tag. Valid tags are: `@gqlType`, `@gqlField`, `@gqlScalar`, `@gqlInterface`, `@gqlEnum`, `@gqlUnion`, `@gqlInput`. +src/tests/fixtures/type_definitions_from_alias/AliasTypeImplementsInterface.invalid.ts:3:5 - error: `@gqlImplements` is not a valid Grats tag. Valid tags are: `@gqlType`, `@gqlField`, `@gqlScalar`, `@gqlInterface`, `@gqlEnum`, `@gqlUnion`, `@gqlInput`, `@gqlExternal`. 3 * @gqlImplements Person ~~~~~~~~~~~~~ diff --git a/src/tests/fixtures/type_definitions_from_interface/InterfaceTypeExtendsGqlInterfaceWithDeprecatedTag.invalid.ts.expected b/src/tests/fixtures/type_definitions_from_interface/InterfaceTypeExtendsGqlInterfaceWithDeprecatedTag.invalid.ts.expected index fbb48e40..c28c5877 100644 --- a/src/tests/fixtures/type_definitions_from_interface/InterfaceTypeExtendsGqlInterfaceWithDeprecatedTag.invalid.ts.expected +++ b/src/tests/fixtures/type_definitions_from_interface/InterfaceTypeExtendsGqlInterfaceWithDeprecatedTag.invalid.ts.expected @@ -27,7 +27,7 @@ src/tests/fixtures/type_definitions_from_interface/InterfaceTypeExtendsGqlInterf ~~~~~~~~~~~~~~~~~~~~~ 10 */ ~ -src/tests/fixtures/type_definitions_from_interface/InterfaceTypeExtendsGqlInterfaceWithDeprecatedTag.invalid.ts:9:5 - error: `@gqlImplements` is not a valid Grats tag. Valid tags are: `@gqlType`, `@gqlField`, `@gqlScalar`, `@gqlInterface`, `@gqlEnum`, `@gqlUnion`, `@gqlInput`. +src/tests/fixtures/type_definitions_from_interface/InterfaceTypeExtendsGqlInterfaceWithDeprecatedTag.invalid.ts:9:5 - error: `@gqlImplements` is not a valid Grats tag. Valid tags are: `@gqlType`, `@gqlField`, `@gqlScalar`, `@gqlInterface`, `@gqlEnum`, `@gqlUnion`, `@gqlInput`, `@gqlExternal`. 9 * @gqlImplements Person ~~~~~~~~~~~~~ diff --git a/src/tests/fixtures/user_error/GqlTagDoesNotExist.ts.expected b/src/tests/fixtures/user_error/GqlTagDoesNotExist.ts.expected index 6da39e5b..3fac6760 100644 --- a/src/tests/fixtures/user_error/GqlTagDoesNotExist.ts.expected +++ b/src/tests/fixtures/user_error/GqlTagDoesNotExist.ts.expected @@ -6,7 +6,7 @@ INPUT ----------------- OUTPUT ----------------- -src/tests/fixtures/user_error/GqlTagDoesNotExist.ts:1:6 - error: `@gqlFiled` is not a valid Grats tag. Valid tags are: `@gqlType`, `@gqlField`, `@gqlScalar`, `@gqlInterface`, `@gqlEnum`, `@gqlUnion`, `@gqlInput`. +src/tests/fixtures/user_error/GqlTagDoesNotExist.ts:1:6 - error: `@gqlFiled` is not a valid Grats tag. Valid tags are: `@gqlType`, `@gqlField`, `@gqlScalar`, `@gqlInterface`, `@gqlEnum`, `@gqlUnion`, `@gqlInput`, `@gqlExternal`. 1 /** @gqlFiled */ ~~~~~~~~ diff --git a/src/tests/test.ts b/src/tests/test.ts index 1a85bf22..5d485787 100644 --- a/src/tests/test.ts +++ b/src/tests/test.ts @@ -150,7 +150,7 @@ const testDirs = [ } else { const sdl = printGratsSDL(doc, parsedOptions.raw.grats); - return `-- SDL --\n${sdl}\n-- TypeScript --\n${executableSchema}`; + return `-- SDL --\n${sdl}-- TypeScript --\n${executableSchema}`; } }, }, diff --git a/src/validations/validateTypenames.ts b/src/validations/validateTypenames.ts index 87efedc8..8810598e 100644 --- a/src/validations/validateTypenames.ts +++ b/src/validations/validateTypenames.ts @@ -39,7 +39,11 @@ export function validateTypenames( ? E.genericTypeImplementsInterface() : E.genericTypeUsedAsUnionMember(); errors.push(gqlErr(ast.name, message)); - } else if (!hasTypename.has(implementor.name) && ast.exported == null) { + } else if ( + !ast.isExternalType && + !hasTypename.has(implementor.name) && + ast.exported == null + ) { const message = type instanceof GraphQLInterfaceType ? E.concreteTypenameImplementingInterfaceCannotBeResolved( From 08a857346598a21ce9111aff99bced0a9ace0c44 Mon Sep 17 00:00:00 2001 From: Mikhail Novikov Date: Mon, 16 Dec 2024 17:24:10 +0100 Subject: [PATCH 05/11] Add async result pipe --- src/utils/Result.ts | 54 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/src/utils/Result.ts b/src/utils/Result.ts index 9d205649..ff77c563 100644 --- a/src/utils/Result.ts +++ b/src/utils/Result.ts @@ -49,6 +49,60 @@ export class ResultPipe { } } +export type PromiseOrValue = T | Promise; + +/** + * Helper class for chaining together a series of `Result` operations that accepts also promises. + */ +export class ResultPipeAsync { + private readonly _result: Promise>; + constructor(result: PromiseOrValue>) { + this._result = Promise.resolve(result); + } + // Transform the value if OK, otherwise return the error. + map(fn: (value: T) => PromiseOrValue): ResultPipeAsync { + return new ResultPipeAsync( + this._result.then((result) => { + if (result.kind === "OK") { + return Promise.resolve(fn(result.value)).then(ok); + } + return result; + }), + ); + } + // Transform the error if ERROR, otherwise return the value. + mapErr(fn: (e: E) => PromiseOrValue): ResultPipeAsync { + return new ResultPipeAsync( + this._result.then((result) => { + if (result.kind === "ERROR") { + return Promise.resolve(fn(result.err)).then(err); + } + return result; + }), + ); + } + // Transform the value into a new result if OK, otherwise return the error. + // The new result may have a new value type, but must have the same error + // type. + andThen( + fn: (value: T) => PromiseOrValue>, + ): ResultPipeAsync { + return new ResultPipeAsync( + this._result.then((result) => { + if (result.kind === "OK") { + return Promise.resolve(fn(result.value)); + } else { + return result; + } + }), + ); + } + // Return the result + result(): Promise> { + return this._result; + } +} + export function collectResults( results: DiagnosticsResult[], ): DiagnosticsResult { From da2556236510f47c917cd115d18329aadd5aaaa6 Mon Sep 17 00:00:00 2001 From: Mikhail Novikov Date: Mon, 16 Dec 2024 18:04:17 +0100 Subject: [PATCH 06/11] Whitespace issues --- src/tests/fixtures/externals/inputTypes.ts.expected | 1 - src/tests/fixtures/externals/interface.ts.expected | 1 - src/tests/fixtures/externals/objectType.ts.expected | 1 - src/tests/fixtures/externals/scalar.ts.expected | 1 - src/tests/fixtures/externals/union.ts.expected | 1 - 5 files changed, 5 deletions(-) diff --git a/src/tests/fixtures/externals/inputTypes.ts.expected b/src/tests/fixtures/externals/inputTypes.ts.expected index b7b7d738..45247c78 100644 --- a/src/tests/fixtures/externals/inputTypes.ts.expected +++ b/src/tests/fixtures/externals/inputTypes.ts.expected @@ -35,7 +35,6 @@ input NestedInput { type Query { myRoot(my: MyInput!, nested: NestedInput!): Int } - -- TypeScript -- import { IResolvers } from "@graphql-tools/utils"; import { myRoot as queryMyRootResolver } from "./inputTypes"; diff --git a/src/tests/fixtures/externals/interface.ts.expected b/src/tests/fixtures/externals/interface.ts.expected index 1e328dff..3f320b9c 100644 --- a/src/tests/fixtures/externals/interface.ts.expected +++ b/src/tests/fixtures/externals/interface.ts.expected @@ -51,7 +51,6 @@ type ImplementingType implements MyInterface { type Query { myRoot: ImplementingType } - -- TypeScript -- import { IResolvers } from "@graphql-tools/utils"; import { someField as implementingTypeSomeFieldResolver, myRoot as queryMyRootResolver } from "./interface"; diff --git a/src/tests/fixtures/externals/objectType.ts.expected b/src/tests/fixtures/externals/objectType.ts.expected index cc0d6d1d..85a5aabf 100644 --- a/src/tests/fixtures/externals/objectType.ts.expected +++ b/src/tests/fixtures/externals/objectType.ts.expected @@ -34,7 +34,6 @@ type Query { extend type MyType { someField: String } - -- TypeScript -- import { IResolvers } from "@graphql-tools/utils"; import { myRoot as queryMyRootResolver, someField as myTypeSomeFieldResolver } from "./objectType"; diff --git a/src/tests/fixtures/externals/scalar.ts.expected b/src/tests/fixtures/externals/scalar.ts.expected index cb556c7b..9e0511de 100644 --- a/src/tests/fixtures/externals/scalar.ts.expected +++ b/src/tests/fixtures/externals/scalar.ts.expected @@ -63,7 +63,6 @@ type NestedObject { type Query { myRoot(my: MyScalar!, nested: NestedInput!): NestedObject } - -- TypeScript -- import { IResolvers } from "@graphql-tools/utils"; import { myRoot as queryMyRootResolver } from "./scalar"; diff --git a/src/tests/fixtures/externals/union.ts.expected b/src/tests/fixtures/externals/union.ts.expected index c149a951..32099c1c 100644 --- a/src/tests/fixtures/externals/union.ts.expected +++ b/src/tests/fixtures/externals/union.ts.expected @@ -26,7 +26,6 @@ OUTPUT type Query { myRoot: MyUnion } - -- TypeScript -- import { IResolvers } from "@graphql-tools/utils"; import { myRoot as queryMyRootResolver } from "./union"; From f312118fa715e0f3cfee705eb358758bf5ccf0ac Mon Sep 17 00:00:00 2001 From: Mikhail Novikov Date: Tue, 17 Dec 2024 12:10:32 +0100 Subject: [PATCH 07/11] Add test for empty type --- .../externals/objectTypeWithoutFields.ts | 14 +++++++ .../objectTypeWithoutFields.ts.expected | 37 +++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 src/tests/fixtures/externals/objectTypeWithoutFields.ts create mode 100644 src/tests/fixtures/externals/objectTypeWithoutFields.ts.expected diff --git a/src/tests/fixtures/externals/objectTypeWithoutFields.ts b/src/tests/fixtures/externals/objectTypeWithoutFields.ts new file mode 100644 index 00000000..bef60e88 --- /dev/null +++ b/src/tests/fixtures/externals/objectTypeWithoutFields.ts @@ -0,0 +1,14 @@ +// { "EXPERIMENTAL__emitResolverMap": true } + +import { SomeType as _SomeType } from "./nonGratsPackage.ignore"; + +/** + * @gqlType MyType + * @gqlExternal "./test-sdl.ignore.graphql" + */ +export type SomeType = _SomeType; + +/** @gqlQueryField */ +export function myRoot(): SomeType | null { + return null; +} diff --git a/src/tests/fixtures/externals/objectTypeWithoutFields.ts.expected b/src/tests/fixtures/externals/objectTypeWithoutFields.ts.expected new file mode 100644 index 00000000..309e4e96 --- /dev/null +++ b/src/tests/fixtures/externals/objectTypeWithoutFields.ts.expected @@ -0,0 +1,37 @@ +----------------- +INPUT +----------------- +// { "EXPERIMENTAL__emitResolverMap": true } + +import { SomeType as _SomeType } from "./nonGratsPackage.ignore"; + +/** + * @gqlType MyType + * @gqlExternal "./test-sdl.ignore.graphql" + */ +export type SomeType = _SomeType; + +/** @gqlQueryField */ +export function myRoot(): SomeType | null { + return null; +} + +----------------- +OUTPUT +----------------- +-- SDL -- +type Query { + myRoot: MyType +} +-- TypeScript -- +import { IResolvers } from "@graphql-tools/utils"; +import { myRoot as queryMyRootResolver } from "./objectTypeWithoutFields"; +export function getResolverMap(): IResolvers { + return { + Query: { + myRoot() { + return queryMyRootResolver(); + } + } + }; +} From 58fedca8620591334949956db82dfced13e9f776 Mon Sep 17 00:00:00 2001 From: Mikhail Novikov Date: Tue, 17 Dec 2024 12:34:08 +0100 Subject: [PATCH 08/11] Minor review comments --- src/Errors.ts | 10 ++-- src/Extractor.ts | 9 ++-- src/lib.ts | 19 ++++--- src/printSchema.ts | 48 +---------------- .../externals/fundamentalErrors.ts.expected | 6 +-- src/transforms/addImportedSchemas.ts | 52 +++++++++---------- 6 files changed, 49 insertions(+), 95 deletions(-) diff --git a/src/Errors.ts b/src/Errors.ts index 65e502ac..a1818548 100644 --- a/src/Errors.ts +++ b/src/Errors.ts @@ -615,15 +615,13 @@ export function tsConfigNotFound(cwd: string) { } export function noModuleInGqlExternal() { - return `Grats: @gqlExternal must include a module name in double quotes. For example: /** @gqlExternal "myModule" */`; + return `\`@${EXTERNAL_TAG}\` must include a module name in double quotes. For example: /** @gqlExternal "myModule" */`; } export function externalNotInResolverMapMode() { - return `Grats: @gqlExternal can only be used if grats is in EXPERIMENTAL__emitResolverMap mode. */`; + return `Unexpected \`@${EXTERNAL_TAG}\` tag. \`@${EXTERNAL_TAG}\` is only supported when the \`EXPERIMENTAL__emitResolverMap\` Grats configuration option is enabled.`; } -export function externalOnWrongNode(extistingTags: string[]) { - return `Unexpected \`@${EXTERNAL_TAG}\` on type with following tags: ${extistingTags.join( - ", ", - )}. \`@${EXTERNAL_TAG}\` can only be used on type declarations.`; +export function externalOnWrongNode(existingTag: string) { + return `Unexpected \`@${EXTERNAL_TAG}\` on type with \`${existingTag}\`. \`@${EXTERNAL_TAG}\` can only be used on type declarations.`; } diff --git a/src/Extractor.ts b/src/Extractor.ts index dbe7ec6b..3b35718c 100644 --- a/src/Extractor.ts +++ b/src/Extractor.ts @@ -254,15 +254,18 @@ class Extractor { if ( !this.hasTag(node, TYPE_TAG) && !this.hasTag(node, INPUT_TAG) && - !this.hasTag(node, INTERFACE_TAG) + !this.hasTag(node, INTERFACE_TAG) && + !this.hasTag(node, UNION_TAG) && + !this.hasTag(node, SCALAR_TAG) && + !this.hasTag(node, ENUM_TAG) ) { this.report( tag.tagName, E.externalOnWrongNode( ts .getJSDocTags(node) - .filter((t) => t.tagName.text !== EXTERNAL_TAG) - .map((t) => t.tagName.escapedText.toString()), + .filter((t) => t.tagName.text !== EXTERNAL_TAG)[0].tagName + .text, ), ); } diff --git a/src/lib.ts b/src/lib.ts index 932cffcc..2f2a1937 100644 --- a/src/lib.ts +++ b/src/lib.ts @@ -34,7 +34,7 @@ import { resolveResolverParams } from "./transforms/resolveResolverParams"; import { customSpecValidations } from "./validations/customSpecValidations"; import { makeResolverSignature } from "./transforms/makeResolverSignature"; import { addImplicitRootTypes } from "./transforms/addImplicitRootTypes"; -import { mergeImportedSchemas } from "./transforms/addImportedSchemas"; +import { addImportedSchemas } from "./transforms/addImportedSchemas"; import { Metadata } from "./metadata"; // Export the TypeScript plugin implementation used by @@ -120,30 +120,29 @@ export function extractSchemaAndDoc( .map((doc) => addImplicitRootTypes(doc)) // Merge any `extend` definitions into their base definitions. .map((doc) => mergeExtensions(ctx, doc)) - .andThen((doc) => mergeImportedSchemas(ctx, doc)) // Perform custom validations that reimplement spec validation rules // with more tailored error messages. - // TODO .andThen((doc) => customSpecValidations(doc)) // Sort the definitions in the document to ensure a stable output. .map((doc) => sortSchemaAst(doc)) + .andThen((doc) => addImportedSchemas(ctx, doc)) .result(); if (docResult.kind === "ERROR") { return docResult; } - const doc = docResult.value; - const resolvers = makeResolverSignature(doc); + const { gratsDoc, externalDocs } = docResult.value; + const resolvers = makeResolverSignature(gratsDoc); // Build and validate the schema with regards to the GraphQL spec. return ( - new ResultPipe(buildSchemaFromDoc(doc)) + new ResultPipe(buildSchemaFromDoc([gratsDoc, ...externalDocs])) // Ensure that every type which implements an interface or is a member of a // union has a __typename field. .andThen((schema) => validateTypenames(schema, typesWithTypename)) .andThen((schema) => validateSemanticNullability(schema, config)) // Combine the schema and document into a single result. - .map((schema) => ({ schema, doc, resolvers })) + .map((schema) => ({ schema, doc: gratsDoc, resolvers })) .result() ); }) @@ -152,12 +151,16 @@ export function extractSchemaAndDoc( // Given a SDL AST, build and validate a GraphQLSchema. function buildSchemaFromDoc( - doc: DocumentNode, + docs: DocumentNode[], ): DiagnosticsWithoutLocationResult { // TODO: Currently this does not detect definitions that shadow builtins // (`String`, `Int`, etc). However, if we pass a second param (extending an // existing schema) we do! So, we should find a way to validate that we don't // shadow builtins. + const doc: DocumentNode = { + kind: Kind.DOCUMENT, + definitions: docs.flatMap((doc) => doc.definitions), + }; const validationErrors = validateSDL(doc); if (validationErrors.length > 0) { return err(validationErrors.map(graphQlErrorToDiagnostic)); diff --git a/src/printSchema.ts b/src/printSchema.ts index facef5d2..223207f9 100644 --- a/src/printSchema.ts +++ b/src/printSchema.ts @@ -49,53 +49,7 @@ export function applySDLHeader(config: GratsConfig, sdl: string): string { export function printSDLWithoutMetadata(doc: DocumentNode): string { const trimmed = visit(doc, { ScalarTypeDefinition(t) { - if (t.isExternalType) { - return null; - } else if ( - specifiedScalarTypes.some((scalar) => scalar.name === t.name.value) - ) { - return null; - } else { - return t; - } - }, - ObjectTypeDefinition(t) { - if (t.isExternalType) { - return null; - } else { - return t; - } - }, - InterfaceTypeDefinition(t) { - if (t.isExternalType) { - return null; - } else { - return t; - } - }, - ObjectTypeExtension(t) { - if (t.isExternalType) { - return null; - } else { - return t; - } - }, - UnionTypeDefinition(t) { - if (t.isExternalType) { - return null; - } else { - return t; - } - }, - EnumTypeDefinition(t) { - if (t.isExternalType) { - return null; - } else { - return t; - } - }, - InputObjectTypeDefinition(t) { - if (t.isExternalType) { + if (specifiedScalarTypes.some((scalar) => scalar.name === t.name.value)) { return null; } else { return t; diff --git a/src/tests/fixtures/externals/fundamentalErrors.ts.expected b/src/tests/fixtures/externals/fundamentalErrors.ts.expected index 378936fa..c0e3db95 100644 --- a/src/tests/fixtures/externals/fundamentalErrors.ts.expected +++ b/src/tests/fixtures/externals/fundamentalErrors.ts.expected @@ -35,15 +35,15 @@ export function myRoot(): SomeType | null { ----------------- OUTPUT ----------------- -src/tests/fixtures/externals/fundamentalErrors.ts:10:5 - error: Grats: @gqlExternal can only be used if grats is in EXPERIMENTAL__emitResolverMap mode. */ +src/tests/fixtures/externals/fundamentalErrors.ts:10:5 - error: Unexpected `@gqlExternal` tag. `@gqlExternal` is only supported when the `EXPERIMENTAL__emitResolverMap` Grats configuration option is enabled. 10 * @gqlExternal "./test-sdl.ignore.graphql" ~~~~~~~~~~~ -src/tests/fixtures/externals/fundamentalErrors.ts:18:1 - error: Grats: @gqlExternal must include a module name in double quotes. For example: /** @gqlExternal "myModule" */ +src/tests/fixtures/externals/fundamentalErrors.ts:18:1 - error: `@gqlExternal` must include a module name in double quotes. For example: /** @gqlExternal "myModule" */ 18 export type OtherType = _SomeType; ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -src/tests/fixtures/externals/fundamentalErrors.ts:16:5 - error: Grats: @gqlExternal can only be used if grats is in EXPERIMENTAL__emitResolverMap mode. */ +src/tests/fixtures/externals/fundamentalErrors.ts:16:5 - error: Unexpected `@gqlExternal` tag. `@gqlExternal` is only supported when the `EXPERIMENTAL__emitResolverMap` Grats configuration option is enabled. 16 * @gqlExternal ~~~~~~~~~~~ diff --git a/src/transforms/addImportedSchemas.ts b/src/transforms/addImportedSchemas.ts index 3350121d..2c25e7f5 100644 --- a/src/transforms/addImportedSchemas.ts +++ b/src/transforms/addImportedSchemas.ts @@ -2,9 +2,9 @@ import * as path from "path"; import * as fs from "fs"; import * as ts from "typescript"; import { - DefinitionNode, DocumentNode, GraphQLError, + isTypeDefinitionNode, Kind, parse, } from "graphql"; @@ -15,40 +15,38 @@ import { } from "../utils/DiagnosticError"; import { err, ok } from "../utils/Result"; -export function mergeImportedSchemas( +export function addImportedSchemas( ctx: TypeContext, doc: DocumentNode, -): DiagnosticsWithoutLocationResult { +): DiagnosticsWithoutLocationResult<{ + gratsDoc: DocumentNode; + externalDocs: DocumentNode[]; +}> { const importedSchemas: Set = new Set(); for (const name of ctx.allNameDefinitions()) { if (name.externalImportPath) { importedSchemas.add(name.externalImportPath); } } - const importedDefinitions: DefinitionNode[] = []; + const externalDocs: DocumentNode[] = []; const errors: ts.Diagnostic[] = []; for (const schemaPath of importedSchemas) { const text = fs.readFileSync(path.resolve(schemaPath), "utf-8"); try { const parsedAst = parse(text); - for (const def of parsedAst.definitions) { - if ( - def.kind === Kind.OPERATION_DEFINITION || - def.kind === Kind.FRAGMENT_DEFINITION || - def.kind === Kind.DIRECTIVE_DEFINITION || - def.kind === Kind.SCHEMA_DEFINITION || - def.kind === Kind.SCHEMA_EXTENSION || - def.kind === Kind.SCALAR_TYPE_EXTENSION || - def.kind === Kind.INTERFACE_TYPE_EXTENSION || - def.kind === Kind.INPUT_OBJECT_TYPE_EXTENSION || - def.kind === Kind.UNION_TYPE_EXTENSION || - def.kind === Kind.ENUM_TYPE_EXTENSION - ) { - continue; - } - def.isExternalType = true; - importedDefinitions.push(def); - } + externalDocs.push({ + kind: Kind.DOCUMENT, + definitions: parsedAst.definitions.map((def) => { + if (isTypeDefinitionNode(def)) { + return { + ...def, + isExternalType: true, + }; + } else { + return def; + } + }), + }); } catch (e) { if (e instanceof GraphQLError) { errors.push(graphQlErrorToDiagnostic(e)); @@ -56,12 +54,10 @@ export function mergeImportedSchemas( } } if (errors.length > 0) { - console.log(errors); return err(errors); } - const newDoc: DocumentNode = { - ...doc, - definitions: [...doc.definitions, ...importedDefinitions], - }; - return ok(newDoc); + return ok({ + gratsDoc: doc, + externalDocs, + }); } From fda55a006e6a641223c6de53cd129ae474d915c3 Mon Sep 17 00:00:00 2001 From: Mikhail Novikov Date: Tue, 17 Dec 2024 12:44:07 +0100 Subject: [PATCH 09/11] Cleaner externalModule --- src/Extractor.ts | 81 +++++++++++++++++++----------------------------- 1 file changed, 32 insertions(+), 49 deletions(-) diff --git a/src/Extractor.ts b/src/Extractor.ts index 3b35718c..98b8a435 100644 --- a/src/Extractor.ts +++ b/src/Extractor.ts @@ -470,11 +470,7 @@ class Extractor { if (ts.isTypeReferenceNode(node)) { if (this.hasTag(node, EXTERNAL_TAG)) { - const externalImportPath = this.externalModule(node); - if (externalImportPath) { - this.recordTypeName(node, name, "UNION", externalImportPath); - return; - } + return this.externalModule(node, name, "UNION"); } return this.report(node, E.nonExternalTypeAlias(UNION_TAG)); } else if (!ts.isUnionTypeNode(node.type)) { @@ -795,11 +791,7 @@ class Extractor { const description = this.collectDescription(node); if (this.hasTag(node, EXTERNAL_TAG)) { - const externalImportPath = this.externalModule(node); - if (externalImportPath) { - this.recordTypeName(node, name, "SCALAR", externalImportPath); - return; - } + return this.externalModule(node, name, "SCALAR"); } this.recordTypeName(node, name, "SCALAR"); @@ -824,17 +816,18 @@ class Extractor { const description = this.collectDescription(node); - let externalImportPath: string | null = null; if ( node.type.kind === ts.SyntaxKind.TypeReference && this.hasTag(node, EXTERNAL_TAG) ) { - externalImportPath = this.externalModule(node); + return this.externalModule(node, name, "INPUT_OBJECT"); } else { const fields = this.collectInputFields(node); const deprecatedDirective = this.collectDeprecated(node); + this.recordTypeName(node, name, "INPUT_OBJECT"); + this.definitions.push( this.gql.inputObjectTypeDefinition( node, @@ -845,8 +838,6 @@ class Extractor { ), ); } - - this.recordTypeName(node, name, "INPUT_OBJECT", externalImportPath); } inputInterfaceDeclaration(node: ts.InterfaceDeclaration, tag: ts.JSDocTag) { @@ -1129,7 +1120,6 @@ class Extractor { let interfaces: NamedTypeNode[] | null = null; let hasTypeName = false; - let externalImportPath: string | null = null; if (ts.isTypeLiteralNode(node.type)) { this.validateOperationTypes(node.type, name.value); @@ -1144,27 +1134,25 @@ class Extractor { node.type.kind === ts.SyntaxKind.TypeReference && this.hasTag(node, EXTERNAL_TAG) ) { - externalImportPath = this.externalModule(node); + return this.externalModule(node, name, "TYPE"); } else { return this.report(node.type, E.typeTagOnAliasOfNonObjectOrUnknown()); } const description = this.collectDescription(node); - this.recordTypeName(node, name, "TYPE", externalImportPath); + this.recordTypeName(node, name, "TYPE"); - if (!externalImportPath) { - this.definitions.push( - this.gql.objectTypeDefinition( - node, - name, - fields, - interfaces, - description, - hasTypeName, - null, - ), - ); - } + this.definitions.push( + this.gql.objectTypeDefinition( + node, + name, + fields, + interfaces, + description, + hasTypeName, + null, + ), + ); } checkForTypenameProperty( @@ -1461,11 +1449,7 @@ class Extractor { node.type.kind === ts.SyntaxKind.TypeReference && this.hasTag(node, EXTERNAL_TAG) ) { - const externalImportPath = this.externalModule(node); - if (externalImportPath) { - this.recordTypeName(node, name, "INTERFACE", externalImportPath); - return; - } + return this.externalModule(node, name, "INTERFACE"); } return this.report(node.type, E.nonExternalTypeAlias(INTERFACE_TAG)); } @@ -1767,7 +1751,7 @@ class Extractor { ); } - enumEnumDeclaration(node: ts.EnumDeclaration, tag: ts.JSDocTag): void { + enumEnumDeclaration(node: ts.EnumDeclaration, tag: ts.JSDocTag) { const name = this.entityName(node, tag); if (name == null || name.value == null) { return; @@ -1783,21 +1767,14 @@ class Extractor { ); } - enumTypeAliasDeclaration( - node: ts.TypeAliasDeclaration, - tag: ts.JSDocTag, - ): void { + enumTypeAliasDeclaration(node: ts.TypeAliasDeclaration, tag: ts.JSDocTag) { const name = this.entityName(node, tag); if (name == null || name.value == null) { return; } if (this.hasTag(node, EXTERNAL_TAG)) { - const externalImportPath = this.externalModule(node); - if (externalImportPath) { - this.recordTypeName(node, name, "ENUM", externalImportPath); - return; - } + return this.externalModule(node, name, "ENUM"); } const values = this.enumTypeAliasVariants(node); @@ -1965,7 +1942,11 @@ class Extractor { return this.gql.name(id, id.text); } - externalModule(node: ts.Node): string | null { + externalModule( + node: ts.DeclarationStatement, + name: NameNode, + kind: NameDefinition["kind"], + ) { const tag = this.findTag(node, EXTERNAL_TAG); if (!tag) { return this.report(node, E.noModuleInGqlExternal()); @@ -1985,9 +1966,11 @@ class Extractor { if (!externalModule) { return this.report(node, E.noModuleInGqlExternal()); } - return path.resolve( - path.dirname(node.getSourceFile().fileName), - externalModule, + return this.recordTypeName( + node, + name, + kind, + path.resolve(path.dirname(node.getSourceFile().fileName), externalModule), ); } From f889bfdb28c1d22edb46b5f2ad0e4becf5e3e358 Mon Sep 17 00:00:00 2001 From: Mikhail Novikov Date: Wed, 5 Mar 2025 12:36:32 +0100 Subject: [PATCH 10/11] Fix tests --- src/Extractor.ts | 1 + ...detachedBlockCommentWithInvalidTagName.invalid.ts.expected | 2 +- .../fixtures/comments/invalidTagInLinecomment.ts.expected | 2 +- .../comments/lineCommentWrongCasing.invalid.ts.expected | 4 ++-- .../ImplementsTagWithoutTypeOrInterface.invalid.ts.expected | 2 +- ...onImplementsInterfaceWithDeprecatedTag.invalid.ts.expected | 2 +- .../AliasTypeImplementsInterface.invalid.ts.expected | 2 +- ...peExtendsGqlInterfaceWithDeprecatedTag.invalid.ts.expected | 2 +- src/tests/fixtures/user_error/GqlTagDoesNotExist.ts.expected | 2 +- 9 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/Extractor.ts b/src/Extractor.ts index 4887afbd..9a0ef890 100644 --- a/src/Extractor.ts +++ b/src/Extractor.ts @@ -1103,6 +1103,7 @@ class Extractor { ) { return this.externalModule(node, name, "INPUT_OBJECT"); } else { + this.recordTypeName(node, name, "INPUT_OBJECT"); let fields: InputValueDefinitionNode[] | null = null; const directives = this.collectDirectives(node); diff --git a/src/tests/fixtures/comments/detachedBlockCommentWithInvalidTagName.invalid.ts.expected b/src/tests/fixtures/comments/detachedBlockCommentWithInvalidTagName.invalid.ts.expected index 3c1107d7..c76507d9 100644 --- a/src/tests/fixtures/comments/detachedBlockCommentWithInvalidTagName.invalid.ts.expected +++ b/src/tests/fixtures/comments/detachedBlockCommentWithInvalidTagName.invalid.ts.expected @@ -11,7 +11,7 @@ INPUT ----------------- OUTPUT ----------------- -src/tests/fixtures/comments/detachedBlockCommentWithInvalidTagName.invalid.ts:2:4 - error: `@gqlTyp` is not a valid Grats tag. Valid tags are: `@gqlType`, `@gqlField`, `@gqlScalar`, `@gqlInterface`, `@gqlEnum`, `@gqlUnion`, `@gqlInput`, `@gqlDirective`, `@gqlAnnotate`. +src/tests/fixtures/comments/detachedBlockCommentWithInvalidTagName.invalid.ts:2:4 - error: `@gqlTyp` is not a valid Grats tag. Valid tags are: `@gqlType`, `@gqlField`, `@gqlScalar`, `@gqlInterface`, `@gqlEnum`, `@gqlUnion`, `@gqlInput`, `@gqlDirective`, `@gqlAnnotate`, `@gqlExternal`. 2 * @gqlTyp ~~~~~~~ diff --git a/src/tests/fixtures/comments/invalidTagInLinecomment.ts.expected b/src/tests/fixtures/comments/invalidTagInLinecomment.ts.expected index 4b14b183..a47c23ea 100644 --- a/src/tests/fixtures/comments/invalidTagInLinecomment.ts.expected +++ b/src/tests/fixtures/comments/invalidTagInLinecomment.ts.expected @@ -12,7 +12,7 @@ export default class Composer { OUTPUT ----------------- -- Error Report -- -src/tests/fixtures/comments/invalidTagInLinecomment.ts:1:4 - error: `@gqlTyp` is not a valid Grats tag. Valid tags are: `@gqlType`, `@gqlField`, `@gqlScalar`, `@gqlInterface`, `@gqlEnum`, `@gqlUnion`, `@gqlInput`, `@gqlDirective`, `@gqlAnnotate`. +src/tests/fixtures/comments/invalidTagInLinecomment.ts:1:4 - error: `@gqlTyp` is not a valid Grats tag. Valid tags are: `@gqlType`, `@gqlField`, `@gqlScalar`, `@gqlInterface`, `@gqlEnum`, `@gqlUnion`, `@gqlInput`, `@gqlDirective`, `@gqlAnnotate`, `@gqlExternal`. 1 // @gqlTyp ~~~~~~~ diff --git a/src/tests/fixtures/comments/lineCommentWrongCasing.invalid.ts.expected b/src/tests/fixtures/comments/lineCommentWrongCasing.invalid.ts.expected index 8753f6a6..5f77f69e 100644 --- a/src/tests/fixtures/comments/lineCommentWrongCasing.invalid.ts.expected +++ b/src/tests/fixtures/comments/lineCommentWrongCasing.invalid.ts.expected @@ -13,7 +13,7 @@ export default class Composer { OUTPUT ----------------- -- Error Report -- -src/tests/fixtures/comments/lineCommentWrongCasing.invalid.ts:1:4 - error: `@GQLtYPE` is not a valid Grats tag. Valid tags are: `@gqlType`, `@gqlField`, `@gqlScalar`, `@gqlInterface`, `@gqlEnum`, `@gqlUnion`, `@gqlInput`, `@gqlDirective`, `@gqlAnnotate`. +src/tests/fixtures/comments/lineCommentWrongCasing.invalid.ts:1:4 - error: `@GQLtYPE` is not a valid Grats tag. Valid tags are: `@gqlType`, `@gqlField`, `@gqlScalar`, `@gqlInterface`, `@gqlEnum`, `@gqlUnion`, `@gqlInput`, `@gqlDirective`, `@gqlAnnotate`, `@gqlExternal`. 1 // @GQLtYPE ~~~~~~~~ @@ -22,7 +22,7 @@ src/tests/fixtures/comments/lineCommentWrongCasing.invalid.ts:1:4 - error: Unexp 1 // @GQLtYPE ~~~~~~~~ -src/tests/fixtures/comments/lineCommentWrongCasing.invalid.ts:3:6 - error: `@gqlfield` is not a valid Grats tag. Valid tags are: `@gqlType`, `@gqlField`, `@gqlScalar`, `@gqlInterface`, `@gqlEnum`, `@gqlUnion`, `@gqlInput`, `@gqlDirective`, `@gqlAnnotate`. +src/tests/fixtures/comments/lineCommentWrongCasing.invalid.ts:3:6 - error: `@gqlfield` is not a valid Grats tag. Valid tags are: `@gqlType`, `@gqlField`, `@gqlScalar`, `@gqlInterface`, `@gqlEnum`, `@gqlUnion`, `@gqlInput`, `@gqlDirective`, `@gqlAnnotate`, `@gqlExternal`. 3 // @gqlfield ~~~~~~~~~ diff --git a/src/tests/fixtures/interfaces/tag/ImplementsTagWithoutTypeOrInterface.invalid.ts.expected b/src/tests/fixtures/interfaces/tag/ImplementsTagWithoutTypeOrInterface.invalid.ts.expected index 511e28af..5674a1aa 100644 --- a/src/tests/fixtures/interfaces/tag/ImplementsTagWithoutTypeOrInterface.invalid.ts.expected +++ b/src/tests/fixtures/interfaces/tag/ImplementsTagWithoutTypeOrInterface.invalid.ts.expected @@ -9,7 +9,7 @@ function hello() { ----------------- OUTPUT ----------------- -src/tests/fixtures/interfaces/tag/ImplementsTagWithoutTypeOrInterface.invalid.ts:1:6 - error: `@gqlImplements` is not a valid Grats tag. Valid tags are: `@gqlType`, `@gqlField`, `@gqlScalar`, `@gqlInterface`, `@gqlEnum`, `@gqlUnion`, `@gqlInput`, `@gqlDirective`, `@gqlAnnotate`. +src/tests/fixtures/interfaces/tag/ImplementsTagWithoutTypeOrInterface.invalid.ts:1:6 - error: `@gqlImplements` is not a valid Grats tag. Valid tags are: `@gqlType`, `@gqlField`, `@gqlScalar`, `@gqlInterface`, `@gqlEnum`, `@gqlUnion`, `@gqlInput`, `@gqlDirective`, `@gqlAnnotate`, `@gqlExternal`. 1 /** @gqlImplements Node */ ~~~~~~~~~~~~~ diff --git a/src/tests/fixtures/type_definitions/TypeFromClassDefinitionImplementsInterfaceWithDeprecatedTag.invalid.ts.expected b/src/tests/fixtures/type_definitions/TypeFromClassDefinitionImplementsInterfaceWithDeprecatedTag.invalid.ts.expected index 55206074..8e7854f5 100644 --- a/src/tests/fixtures/type_definitions/TypeFromClassDefinitionImplementsInterfaceWithDeprecatedTag.invalid.ts.expected +++ b/src/tests/fixtures/type_definitions/TypeFromClassDefinitionImplementsInterfaceWithDeprecatedTag.invalid.ts.expected @@ -26,7 +26,7 @@ src/tests/fixtures/type_definitions/TypeFromClassDefinitionImplementsInterfaceWi ~~~~~~~~~~~~~~~~~~~~~ 10 */ ~ -src/tests/fixtures/type_definitions/TypeFromClassDefinitionImplementsInterfaceWithDeprecatedTag.invalid.ts:9:5 - error: `@gqlImplements` is not a valid Grats tag. Valid tags are: `@gqlType`, `@gqlField`, `@gqlScalar`, `@gqlInterface`, `@gqlEnum`, `@gqlUnion`, `@gqlInput`, `@gqlDirective`, `@gqlAnnotate`. +src/tests/fixtures/type_definitions/TypeFromClassDefinitionImplementsInterfaceWithDeprecatedTag.invalid.ts:9:5 - error: `@gqlImplements` is not a valid Grats tag. Valid tags are: `@gqlType`, `@gqlField`, `@gqlScalar`, `@gqlInterface`, `@gqlEnum`, `@gqlUnion`, `@gqlInput`, `@gqlDirective`, `@gqlAnnotate`, `@gqlExternal`. 9 * @gqlImplements Person ~~~~~~~~~~~~~ diff --git a/src/tests/fixtures/type_definitions_from_alias/AliasTypeImplementsInterface.invalid.ts.expected b/src/tests/fixtures/type_definitions_from_alias/AliasTypeImplementsInterface.invalid.ts.expected index afcf4976..31b1277c 100644 --- a/src/tests/fixtures/type_definitions_from_alias/AliasTypeImplementsInterface.invalid.ts.expected +++ b/src/tests/fixtures/type_definitions_from_alias/AliasTypeImplementsInterface.invalid.ts.expected @@ -26,7 +26,7 @@ src/tests/fixtures/type_definitions_from_alias/AliasTypeImplementsInterface.inva ~~~~~~~~~~~~~~~~~~~~~ 4 */ ~ -src/tests/fixtures/type_definitions_from_alias/AliasTypeImplementsInterface.invalid.ts:3:5 - error: `@gqlImplements` is not a valid Grats tag. Valid tags are: `@gqlType`, `@gqlField`, `@gqlScalar`, `@gqlInterface`, `@gqlEnum`, `@gqlUnion`, `@gqlInput`, `@gqlDirective`, `@gqlAnnotate`. +src/tests/fixtures/type_definitions_from_alias/AliasTypeImplementsInterface.invalid.ts:3:5 - error: `@gqlImplements` is not a valid Grats tag. Valid tags are: `@gqlType`, `@gqlField`, `@gqlScalar`, `@gqlInterface`, `@gqlEnum`, `@gqlUnion`, `@gqlInput`, `@gqlDirective`, `@gqlAnnotate`, `@gqlExternal`. 3 * @gqlImplements Person ~~~~~~~~~~~~~ diff --git a/src/tests/fixtures/type_definitions_from_interface/InterfaceTypeExtendsGqlInterfaceWithDeprecatedTag.invalid.ts.expected b/src/tests/fixtures/type_definitions_from_interface/InterfaceTypeExtendsGqlInterfaceWithDeprecatedTag.invalid.ts.expected index e46505e9..cbc824d0 100644 --- a/src/tests/fixtures/type_definitions_from_interface/InterfaceTypeExtendsGqlInterfaceWithDeprecatedTag.invalid.ts.expected +++ b/src/tests/fixtures/type_definitions_from_interface/InterfaceTypeExtendsGqlInterfaceWithDeprecatedTag.invalid.ts.expected @@ -27,7 +27,7 @@ src/tests/fixtures/type_definitions_from_interface/InterfaceTypeExtendsGqlInterf ~~~~~~~~~~~~~~~~~~~~~ 10 */ ~ -src/tests/fixtures/type_definitions_from_interface/InterfaceTypeExtendsGqlInterfaceWithDeprecatedTag.invalid.ts:9:5 - error: `@gqlImplements` is not a valid Grats tag. Valid tags are: `@gqlType`, `@gqlField`, `@gqlScalar`, `@gqlInterface`, `@gqlEnum`, `@gqlUnion`, `@gqlInput`, `@gqlDirective`, `@gqlAnnotate`. +src/tests/fixtures/type_definitions_from_interface/InterfaceTypeExtendsGqlInterfaceWithDeprecatedTag.invalid.ts:9:5 - error: `@gqlImplements` is not a valid Grats tag. Valid tags are: `@gqlType`, `@gqlField`, `@gqlScalar`, `@gqlInterface`, `@gqlEnum`, `@gqlUnion`, `@gqlInput`, `@gqlDirective`, `@gqlAnnotate`, `@gqlExternal`. 9 * @gqlImplements Person ~~~~~~~~~~~~~ diff --git a/src/tests/fixtures/user_error/GqlTagDoesNotExist.ts.expected b/src/tests/fixtures/user_error/GqlTagDoesNotExist.ts.expected index bea4c810..d585bd6e 100644 --- a/src/tests/fixtures/user_error/GqlTagDoesNotExist.ts.expected +++ b/src/tests/fixtures/user_error/GqlTagDoesNotExist.ts.expected @@ -6,7 +6,7 @@ INPUT ----------------- OUTPUT ----------------- -src/tests/fixtures/user_error/GqlTagDoesNotExist.ts:1:6 - error: `@gqlFiled` is not a valid Grats tag. Valid tags are: `@gqlType`, `@gqlField`, `@gqlScalar`, `@gqlInterface`, `@gqlEnum`, `@gqlUnion`, `@gqlInput`, `@gqlDirective`, `@gqlAnnotate`. +src/tests/fixtures/user_error/GqlTagDoesNotExist.ts:1:6 - error: `@gqlFiled` is not a valid Grats tag. Valid tags are: `@gqlType`, `@gqlField`, `@gqlScalar`, `@gqlInterface`, `@gqlEnum`, `@gqlUnion`, `@gqlInput`, `@gqlDirective`, `@gqlAnnotate`, `@gqlExternal`. 1 /** @gqlFiled */ ~~~~~~~~ From 78cb2c2854b1b7fc78e551f26258b43cc93432ad Mon Sep 17 00:00:00 2001 From: Mikhail Novikov Date: Wed, 5 Mar 2025 16:21:46 +0100 Subject: [PATCH 11/11] Review comments --- src/Errors.ts | 13 +++++-- src/Extractor.ts | 40 ++++++++++----------- src/transforms/addImportedSchemas.ts | 3 +- src/utils/Result.ts | 52 ---------------------------- 4 files changed, 32 insertions(+), 76 deletions(-) diff --git a/src/Errors.ts b/src/Errors.ts index 0c205b23..f26b539c 100644 --- a/src/Errors.ts +++ b/src/Errors.ts @@ -15,6 +15,7 @@ import { EXTERNAL_TAG, AllTags, DIRECTIVE_TAG, + EXTERNAL_TAG_VALID_TAGS, } from "./Extractor"; export const ISSUE_URL = "https://github.com/captbaritone/grats/issues"; @@ -653,6 +654,14 @@ export function externalNotInResolverMapMode() { return `Unexpected \`@${EXTERNAL_TAG}\` tag. \`@${EXTERNAL_TAG}\` is only supported when the \`EXPERIMENTAL__emitResolverMap\` Grats configuration option is enabled.`; } -export function externalOnWrongNode(existingTag: string) { - return `Unexpected \`@${EXTERNAL_TAG}\` on type with \`${existingTag}\`. \`@${EXTERNAL_TAG}\` can only be used on type declarations.`; +export function externalOnWrongNode(existingTag?: string) { + if (existingTag) { + return `Unexpected \`@${EXTERNAL_TAG}\` on type with \`@${existingTag}\`. \`@${EXTERNAL_TAG}\` can only be used with ${EXTERNAL_TAG_VALID_TAGS.map( + (tag) => `\`@${tag}\``, + ).join(", ")}.`; + } else { + return `Unexpected \`@${EXTERNAL_TAG}\` without a Grats tag. \`@${EXTERNAL_TAG}\` must be used with ${EXTERNAL_TAG_VALID_TAGS.map( + (tag) => `\`@${tag}\``, + ).join(", ")}.`; + } } diff --git a/src/Extractor.ts b/src/Extractor.ts index 9a0ef890..57702a53 100644 --- a/src/Extractor.ts +++ b/src/Extractor.ts @@ -75,16 +75,6 @@ export const KILLS_PARENT_ON_EXCEPTION_TAG = "killsParentOnException"; export const EXTERNAL_TAG = "gqlExternal"; -export type AllTags = - | typeof TYPE_TAG - | typeof FIELD_TAG - | typeof SCALAR_TAG - | typeof INTERFACE_TAG - | typeof ENUM_TAG - | typeof UNION_TAG - | typeof INPUT_TAG - | typeof EXTERNAL_TAG; - // All the tags that start with gql export const ALL_TAGS = [ TYPE_TAG, @@ -97,6 +87,16 @@ export const ALL_TAGS = [ DIRECTIVE_TAG, ANNOTATE_TAG, EXTERNAL_TAG, +] as const; +export type AllTags = (typeof ALL_TAGS)[number]; + +export const EXTERNAL_TAG_VALID_TAGS = [ + TYPE_TAG, + INPUT_TAG, + INTERFACE_TAG, + UNION_TAG, + SCALAR_TAG, + ENUM_TAG, ]; const DEPRECATED_TAG = "deprecated"; @@ -172,7 +172,8 @@ class Extractor { this.nameDefinitions.set(node, { name, kind, externalImportPath }); } - // Traverse all nodes, checking each one for its JSDoc tags. // If we find a tag we recognize, we extract the relevant information, + // Traverse all nodes, checking each one for its JSDoc tags. + // If we find a tag we recognize, we extract the relevant information, // reporting an error if it is attached to a node where that tag is not // supported. extract(sourceFile: ts.SourceFile): DiagnosticsResult { @@ -296,22 +297,19 @@ class Extractor { case EXTERNAL_TAG: if (!this._options.EXPERIMENTAL__emitResolverMap) { this.report(tag.tagName, E.externalNotInResolverMapMode()); - } - if ( - !this.hasTag(node, TYPE_TAG) && - !this.hasTag(node, INPUT_TAG) && - !this.hasTag(node, INTERFACE_TAG) && - !this.hasTag(node, UNION_TAG) && - !this.hasTag(node, SCALAR_TAG) && - !this.hasTag(node, ENUM_TAG) + } else if ( + !EXTERNAL_TAG_VALID_TAGS.some((tag) => this.hasTag(node, tag)) ) { this.report( tag.tagName, E.externalOnWrongNode( ts .getJSDocTags(node) - .filter((t) => t.tagName.text !== EXTERNAL_TAG)[0].tagName - .text, + .filter( + (t) => + t.tagName.text !== EXTERNAL_TAG && + ALL_TAGS.includes(t.tagName.text as AllTags), + )[0]?.tagName.text, ), ); } diff --git a/src/transforms/addImportedSchemas.ts b/src/transforms/addImportedSchemas.ts index 2c25e7f5..0eb86bbd 100644 --- a/src/transforms/addImportedSchemas.ts +++ b/src/transforms/addImportedSchemas.ts @@ -7,6 +7,7 @@ import { isTypeDefinitionNode, Kind, parse, + Source, } from "graphql"; import { TypeContext } from "../TypeContext"; import { @@ -33,7 +34,7 @@ export function addImportedSchemas( for (const schemaPath of importedSchemas) { const text = fs.readFileSync(path.resolve(schemaPath), "utf-8"); try { - const parsedAst = parse(text); + const parsedAst = parse(new Source(text, schemaPath)); externalDocs.push({ kind: Kind.DOCUMENT, definitions: parsedAst.definitions.map((def) => { diff --git a/src/utils/Result.ts b/src/utils/Result.ts index ff77c563..3e0794bc 100644 --- a/src/utils/Result.ts +++ b/src/utils/Result.ts @@ -51,58 +51,6 @@ export class ResultPipe { export type PromiseOrValue = T | Promise; -/** - * Helper class for chaining together a series of `Result` operations that accepts also promises. - */ -export class ResultPipeAsync { - private readonly _result: Promise>; - constructor(result: PromiseOrValue>) { - this._result = Promise.resolve(result); - } - // Transform the value if OK, otherwise return the error. - map(fn: (value: T) => PromiseOrValue): ResultPipeAsync { - return new ResultPipeAsync( - this._result.then((result) => { - if (result.kind === "OK") { - return Promise.resolve(fn(result.value)).then(ok); - } - return result; - }), - ); - } - // Transform the error if ERROR, otherwise return the value. - mapErr(fn: (e: E) => PromiseOrValue): ResultPipeAsync { - return new ResultPipeAsync( - this._result.then((result) => { - if (result.kind === "ERROR") { - return Promise.resolve(fn(result.err)).then(err); - } - return result; - }), - ); - } - // Transform the value into a new result if OK, otherwise return the error. - // The new result may have a new value type, but must have the same error - // type. - andThen( - fn: (value: T) => PromiseOrValue>, - ): ResultPipeAsync { - return new ResultPipeAsync( - this._result.then((result) => { - if (result.kind === "OK") { - return Promise.resolve(fn(result.value)); - } else { - return result; - } - }), - ); - } - // Return the result - result(): Promise> { - return this._result; - } -} - export function collectResults( results: DiagnosticsResult[], ): DiagnosticsResult {