Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

[WIP] Require serialization/deserialization functions for custom scalars #94

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/Errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,10 @@ export function functionFieldNotNamedExport() {
return `Expected a \`@${FIELD_TAG}\` function to be a named export. Grats needs to import resolver functions into it's generated schema module, so the resolver function must be a named export.`;
}

export function customScalarTypeNotExported() {
return `Expected a \`@${SCALAR_TAG}\` type to be a named export. Grats needs to import custom scalar types into it's generated schema module, so the type must be a named export.`;
}

export function inputTypeNotLiteral() {
return `\`@${INPUT_TAG}\` can only be used on type literals. e.g. \`type MyInput = { foo: string }\``;
}
Expand Down
26 changes: 25 additions & 1 deletion src/Extractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -435,15 +435,39 @@ class Extractor {
return node.name;
}

typeAliasExportName(node: ts.TypeAliasDeclaration): ts.Identifier | null {
const exportKeyword = node.modifiers?.some((modifier) => {
return modifier.kind === ts.SyntaxKind.ExportKeyword;
});
if (exportKeyword == null) {
return this.report(node.name, E.customScalarTypeNotExported());
}
return node.name;
}

scalarTypeAliasDeclaration(node: ts.TypeAliasDeclaration, tag: ts.JSDocTag) {
const name = this.entityName(node, tag);
if (name == null) return null;

const description = this.collectDescription(node);
this.recordTypeName(node.name, name, "SCALAR");

// Ensure the type is exported
const exportName = this.typeAliasExportName(node);
if (exportName == null) return null;

const tsModulePath = relativePath(node.getSourceFile().fileName);

const directives = [
this.gql.exportedDirective(exportName, {
tsModulePath,
exportedFunctionName: exportName.text,
argCount: 0,
}),
];

this.definitions.push(
this.gql.scalarTypeDefinition(node, name, description),
this.gql.scalarTypeDefinition(node, name, directives, description),
);
}

Expand Down
3 changes: 2 additions & 1 deletion src/GraphQLConstructor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,14 +228,15 @@ export class GraphQLConstructor {
scalarTypeDefinition(
node: ts.Node,
name: NameNode,
directives: readonly ConstDirectiveNode[] | null,
description: StringValueNode | null,
): ScalarTypeDefinitionNode {
return {
kind: Kind.SCALAR_TYPE_DEFINITION,
loc: this._loc(node),
description: description ?? undefined,
name,
directives: undefined,
directives: this._optionalList(directives),
};
}

Expand Down
182 changes: 169 additions & 13 deletions src/codegen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,19 @@ import {
} from "./metadataDirectives";
import { resolveRelativePath } from "./gratsRoot";

const SCHEMA_CONFIG_TYPE_NAME = "SchemaConfigType";
const SCHEMA_CONFIG_NAME = "config";
const SCHEMA_CONFIG_SCALARS_NAME = "scalars";

const SCALAR_CONFIG_TYPE_NAME = "ScalarConfigType";
const PRIMITIVE_TYPE_NAMES = new Set([
"String",
"Int",
"Float",
"Boolean",
"ID",
]);

const F = ts.factory;

// Given a GraphQL SDL, returns the a string of TypeScript code that generates a
Expand Down Expand Up @@ -74,31 +87,140 @@ class Codegen {
return F.createIdentifier(name);
}

graphQLTypeImport(name: string): ts.TypeReferenceNode {
graphQLTypeImport(
name: string,
typeArguments?: readonly ts.TypeNode[],
): ts.TypeReferenceNode {
this._graphQLImports.add(name);
return F.createTypeReferenceNode(name);
return F.createTypeReferenceNode(name, typeArguments);
}

schemaDeclarationExport(): void {
const schemaConfigType = this.schemaConfigTypeDeclaration();
const params: ts.ParameterDeclaration[] = [];
if (schemaConfigType != null) {
this._statements.push(schemaConfigType);
params.push(
F.createParameterDeclaration(
undefined,
undefined,
SCHEMA_CONFIG_NAME,
undefined,
F.createTypeReferenceNode(SCHEMA_CONFIG_TYPE_NAME),
),
);
}
this.functionDeclaration(
"getSchema",
[F.createModifier(ts.SyntaxKind.ExportKeyword)],
params,
this.graphQLTypeImport("GraphQLSchema"),
this.createBlockWithScope(() => {
this._statements.push(
F.createReturnStatement(
F.createNewExpression(
this.graphQLImport("GraphQLSchema"),
[],
[this.schemaConfig()],
[this.schemaConfigObject()],
),
),
);
}),
);
}

schemaConfig(): ts.ObjectLiteralExpression {
schemaConfigTypeDeclaration(): ts.TypeAliasDeclaration | null {
const configType = this.schemaConfigType();
if (configType == null) return null;
return F.createTypeAliasDeclaration(
[F.createModifier(ts.SyntaxKind.ExportKeyword)],
SCHEMA_CONFIG_TYPE_NAME,
undefined,
configType,
);
}

schemaConfigType(): ts.TypeLiteralNode | null {
const scalarType = this.schemaConfigScalarType();
if (scalarType == null) return null;
return F.createTypeLiteralNode([scalarType]);
}

schemaConfigScalarType(): ts.TypeElement | null {
const typeMap = this._schema.getTypeMap();
const scalarTypes = Object.values(typeMap)
.filter(isScalarType)
.filter((scalar) => {
// Built in primitives
return !PRIMITIVE_TYPE_NAMES.has(scalar.name);
});
if (scalarTypes.length == 0) return null;
this._statements.push(this.scalarConfigTypeDeclaration());
return F.createPropertySignature(
undefined,
SCHEMA_CONFIG_SCALARS_NAME,
undefined,
F.createTypeLiteralNode(
scalarTypes.map((scalar) => {
return F.createPropertySignature(
undefined,
scalar.name,
undefined,
F.createTypeReferenceNode(SCALAR_CONFIG_TYPE_NAME, [
F.createTypeReferenceNode(
formatCustomScalarTypeName(scalar.name),
),
]),
);
}),
),
);
}

scalarConfigTypeDeclaration(): ts.TypeAliasDeclaration {
return F.createTypeAliasDeclaration(
undefined,
SCALAR_CONFIG_TYPE_NAME,
[F.createTypeParameterDeclaration(undefined, "T")],
F.createTypeLiteralNode([
F.createMethodSignature(
undefined,
"serialize",
undefined,
undefined,
[
F.createParameterDeclaration(
undefined,
undefined,
"outputValue",
undefined,
F.createTypeReferenceNode("T"),
),
],
F.createKeywordTypeNode(ts.SyntaxKind.AnyKeyword),
),
F.createPropertySignature(
undefined,
"parseValue",
undefined,

this.graphQLTypeImport("GraphQLScalarValueParser", [
F.createTypeReferenceNode("T"),
]),
),
F.createPropertySignature(
undefined,
"parseLiteral",
undefined,
this.graphQLTypeImport("GraphQLScalarLiteralParser", [
F.createTypeReferenceNode("T"),
]),
),
]),
);
}

schemaConfigObject(): ts.ObjectLiteralExpression {
return this.objectLiteral([
this.description(this._schema.description),
this.query(),
Expand All @@ -115,12 +237,7 @@ class Codegen {
type.name.startsWith("__") ||
type.name.startsWith("Introspection") ||
type.name.startsWith("Schema") ||
// Built in primitives
type.name === "String" ||
type.name === "Int" ||
type.name === "Float" ||
type.name === "Boolean" ||
type.name === "ID"
PRIMITIVE_TYPE_NAMES.has(type.name)
);
})
.map((type) => this.typeReference(type));
Expand Down Expand Up @@ -393,7 +510,7 @@ class Codegen {
varName,
F.createNewExpression(
this.graphQLImport("GraphQLScalarType"),
[],
[F.createTypeReferenceNode(formatCustomScalarTypeName(obj.name))],
[this.customScalarTypeConfig(obj)],
),
// We need to explicitly specify the type due to circular references in
Expand All @@ -405,9 +522,43 @@ class Codegen {
}

customScalarTypeConfig(obj: GraphQLScalarType): ts.ObjectLiteralExpression {
const exported = fieldDirective(obj, EXPORTED_DIRECTIVE);
if (exported != null) {
const exportedMetadata = parseExportedDirective(exported);
const module = exportedMetadata.tsModulePath;
const funcName = exportedMetadata.exportedFunctionName;
const abs = resolveRelativePath(module);
const relative = stripExt(
path.relative(path.dirname(this._destination), abs),
);
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO: Extract this so it can be shared


const scalarTypeName = formatCustomScalarTypeName(obj.name);
this.import(`./${relative}`, [{ name: funcName, as: scalarTypeName }]);
}
return this.objectLiteral([
this.description(obj.description),
F.createPropertyAssignment("name", F.createStringLiteral(obj.name)),
...["serialize", "parseValue", "parseLiteral"].map((name) => {
let func: ts.Expression = F.createPropertyAccessExpression(
F.createPropertyAccessExpression(
F.createPropertyAccessExpression(
F.createIdentifier(SCHEMA_CONFIG_NAME),
SCHEMA_CONFIG_SCALARS_NAME,
),
obj.name,
),
name,
);
if (name === "serialize") {
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO: Comment explaining why we do this

func = F.createAsExpression(
func,
this.graphQLTypeImport("GraphQLScalarSerializer", [
F.createTypeReferenceNode(formatCustomScalarTypeName(obj.name)),
]),
);
}
return F.createPropertyAssignment(name, func);
}),
]);
}

Expand Down Expand Up @@ -663,6 +814,7 @@ class Codegen {
functionDeclaration(
name: string,
modifiers: ts.Modifier[] | undefined,
parameters: ts.ParameterDeclaration[],
type: ts.TypeNode | undefined,
body: ts.Block,
): void {
Expand All @@ -672,7 +824,7 @@ class Codegen {
undefined,
name,
undefined,
[],
parameters,
type,
body,
),
Expand Down Expand Up @@ -769,7 +921,7 @@ class Codegen {
}

function fieldDirective(
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO: Rename?

field: GraphQLField<unknown, unknown>,
field: GraphQLField<unknown, unknown> | GraphQLScalarType,
name: string,
): ConstDirectiveNode | null {
return field.astNode?.directives?.find((d) => d.name.value === name) ?? null;
Expand All @@ -794,3 +946,7 @@ function formatResolverFunctionVarName(
const field = fieldName[0].toUpperCase() + fieldName.slice(1);
return `${parent}${field}Resolver`;
}

function formatCustomScalarTypeName(scalarName: string): string {
return `${scalarName}Type`;
}
2 changes: 1 addition & 1 deletion src/metadataDirectives.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export const DIRECTIVES_AST: DocumentNode = parse(`
${TS_MODULE_PATH_ARG}: String!,
${EXPORTED_FUNCTION_NAME_ARG}: String!
${ARG_COUNT}: Int!
) on FIELD_DEFINITION
) on FIELD_DEFINITION | SCALAR
directive @${KILLS_PARENT_ON_EXCEPTION_DIRECTIVE} on FIELD_DEFINITION
`);

Expand Down
2 changes: 1 addition & 1 deletion src/tests/fixtures/arguments/CustomScalarArgument.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/** @gqlScalar */
type MyString = string;
export type MyString = string;

/** @gqlType */
export default class SomeType {
Expand Down
26 changes: 20 additions & 6 deletions src/tests/fixtures/arguments/CustomScalarArgument.ts.expected
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
INPUT
-----------------
/** @gqlScalar */
type MyString = string;
export type MyString = string;

/** @gqlType */
export default class SomeType {
Expand All @@ -16,16 +16,30 @@ export default class SomeType {
OUTPUT
-----------------
-- SDL --
scalar MyString
scalar MyString @exported(tsModulePath: "grats/src/tests/fixtures/arguments/CustomScalarArgument.ts", functionName: "MyString", argCount: 0)

type SomeType {
hello(greeting: MyString!): String
}
-- TypeScript --
import { GraphQLSchema, GraphQLScalarType, GraphQLObjectType, GraphQLString, GraphQLNonNull } from "graphql";
export function getSchema(): GraphQLSchema {
const MyStringType: GraphQLScalarType = new GraphQLScalarType({
name: "MyString"
import { MyString as MyStringType } from "./CustomScalarArgument";
import { GraphQLScalarValueParser, GraphQLScalarLiteralParser, GraphQLSchema, GraphQLScalarType, GraphQLScalarSerializer, GraphQLObjectType, GraphQLString, GraphQLNonNull } from "graphql";
type ScalarConfigType<T> = {
serialize(outputValue: T): any;
parseValue: GraphQLScalarValueParser<T>;
parseLiteral: GraphQLScalarLiteralParser<T>;
};
export type SchemaConfigType = {
scalars: {
MyString: ScalarConfigType<MyStringType>;
};
};
export function getSchema(config: SchemaConfigType): GraphQLSchema {
const MyStringType: GraphQLScalarType = new GraphQLScalarType<MyStringType>({
name: "MyString",
serialize: config.scalars.MyString.serialize as GraphQLScalarSerializer<MyStringType>,
parseValue: config.scalars.MyString.parseValue,
parseLiteral: config.scalars.MyString.parseLiteral
});
const SomeTypeType: GraphQLObjectType = new GraphQLObjectType({
name: "SomeType",
Expand Down
Loading
Loading