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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 58 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#!/usr/bin/env node

import * as E from "./Errors";
import { GraphQLNamedType, GraphQLObjectType, Location } from "graphql";
import { GraphQLNamedType, GraphQLObjectType, Location, parse } from "graphql";
import { getParsedTsConfig } from "./";
import {
SchemaAndDoc,
Expand All @@ -10,7 +10,7 @@ import {
} from "./lib";
import { Command } from "commander";
import { writeFileSync } from "fs";
import { resolve, dirname } from "path";
import { resolve, dirname, join } from "node:path";
import { version } from "../package.json";
import { locate } from "./Locate";
import { printGratsSDL, printExecutableSchema } from "./printSchema";
Expand All @@ -21,6 +21,9 @@ import {
} from "./utils/DiagnosticError";
import { GratsConfig, ParsedCommandLineGrats } from "./gratsConfig";
import { err, ok, Result } from "./utils/Result";
import * as fs from "fs";
import { resolverMapCodegen } from "./codegen/resolverMapCodegen";
import { filterMetadata, extractUsedFields } from "./constructUsedMetadata";

const program = new Command();

Expand Down Expand Up @@ -61,6 +64,59 @@ program
console.log(formatLoc(loc.value));
});

program
.command("persist")
.argument("<OPERATION_TEXT>", "Text of the GraphQL operation to persist")
.option(
"--tsconfig <TSCONFIG>",
"Path to tsconfig.json. Defaults to auto-detecting based on the current working directory",
)
.action((operationText, { tsconfig }) => {
const { config, configPath } = handleDiagnostics(getTsConfig(tsconfig));

const schemaAndDocResult = buildSchemaAndDocResult(config);
if (schemaAndDocResult.kind === "ERROR") {
console.error(
schemaAndDocResult.err.formatDiagnosticsWithColorAndContext(),
);
process.exit(1);
}

const { schema, resolvers } = schemaAndDocResult.value;

if (operationText === "-") {
operationText = fs.readFileSync(0, "utf-8");
}

// TODO: Turn parse errors into diagnostics.
const doc = parse(operationText, { noLocation: true });

if (doc.definitions.some((def) => def.kind !== "OperationDefinition")) {
// TODO: Diagnostics?
throw new Error("Expected all definitions to be operations.");
}

const name = "placeholder_name";

const destDir = resolve(dirname(configPath), `./persisted`);
const dest = join(destDir, `${name}.ts`);

const usedResolverMap = extractUsedFields(schema, doc);

const newResolverMap = filterMetadata(usedResolverMap, resolvers);

const result = resolverMapCodegen(
schema,
newResolverMap,
config.raw.grats,
dest,
);

fs.mkdirSync(destDir, { recursive: true });
writeFileSync(dest, result);
console.error(`Grats: Wrote TypeScript operation to \`${dest}\`.`);
});

program.parse();

/**
Expand Down
89 changes: 89 additions & 0 deletions src/constructUsedMetadata.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { Metadata } from "./metadata";

import {
DocumentNode,
GraphQLSchema,
visitWithTypeInfo,
TypeInfo,
visit,
Kind,
getNamedType,
isAbstractType,
isObjectType,
FieldNode,
} from "graphql";

// Map of used concrete typenames to a set of used fields on that type.
export type UsedFields = Map<string, Set<string>>;

// Given a GraphQL schema and a document (which may contain multiple
// operations), produce a `UsedFields` map representing the concrete fields used
// in the document. Useful for building sub-schema resolver maps scoped to a set
// of operations, such as all the queries included in a JS bundle.
export function extractUsedFields(
schema: GraphQLSchema,
operation: DocumentNode,
): UsedFields {
const usedSchemaMap: UsedFields = new Map();
const typeInfo = new TypeInfo(schema);

function addField(typeName: string, fieldName: string) {
let fieldSet = usedSchemaMap.get(typeName);
if (fieldSet == null) {
fieldSet = new Set();
usedSchemaMap.set(typeName, fieldSet);
}
fieldSet.add(fieldName);
}

const visitor = {
[Kind.FIELD](field: FieldNode) {
const type = getNamedType(typeInfo.getParentType());
if (isObjectType(type)) {
addField(type.name, field.name.value);
} else if (isAbstractType(type)) {
const possibleTypes = schema.getPossibleTypes(type);
for (const possibleType of possibleTypes) {
addField(possibleType.name, field.name.value);
}
}
},
};

visit(operation, visitWithTypeInfo(typeInfo, visitor));
return usedSchemaMap;
}

/**
* Given a set of used fields, filter a metadata object to only include
* information about the used fields.
*
* Useful for constructing a Metadata object representing a subset of the graph,
* such as:
*
* - A single query or mutation operation
* - All the queries/mutations used in a given JS bundle
*/
export function filterMetadata(
usedFields: UsedFields,
metadata: Metadata,
): Metadata {
const newMetadata: Metadata = { types: {} };

for (const [typeName, fields] of Object.entries(metadata.types)) {
const usedFieldsForType = usedFields.get(typeName);
if (!usedFieldsForType) {
continue;
}
const newFields = {};
for (const [fieldName, field] of Object.entries(fields)) {
if (usedFieldsForType.has(fieldName)) {
newFields[fieldName] = field;
}
}
if (Object.keys(newFields).length > 0) {
newMetadata.types[typeName] = newFields;
}
}
return newMetadata;
}
39 changes: 39 additions & 0 deletions src/tests/presentence/interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/** @gqlType */
type Query = unknown;

/** @gqlField */
export function viewer(_: Query): Person {
return new User();
}

/** @gqlInterface */
interface Person {
/** @gqlField */
name(foo: string | null): string;
}

/** @gqlType */
export class User implements Person {
__typename = "User" as const;
/** @gqlField */
name(foo: string | null): string {
return "Alice";
}
}

/** @gqlType */
export class Admin implements Person {
__typename = "Admin" as const;
/** @gqlField */
name(foo: string | null): string {
return "Alice";
}
}

export const operation = `
query {
greeting
viewer {
name
}
}`;
67 changes: 67 additions & 0 deletions src/tests/presentence/interface.ts.expected
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
-----------------
INPUT
-----------------
/** @gqlType */
type Query = unknown;

/** @gqlField */
export function viewer(_: Query): Person {
return new User();
}

/** @gqlInterface */
interface Person {
/** @gqlField */
name(foo: string | null): string;
}

/** @gqlType */
export class User implements Person {
__typename = "User" as const;
/** @gqlField */
name(foo: string | null): string {
return "Alice";
}
}

/** @gqlType */
export class Admin implements Person {
__typename = "Admin" as const;
/** @gqlField */
name(foo: string | null): string {
return "Alice";
}
}

export const operation = `
query {
greeting
viewer {
name
}
}`;

-----------------
OUTPUT
-----------------
import { IResolvers } from "@graphql-tools/utils";
import { viewer as queryViewerResolver } from "./src/tests/presentence/interface";
export function getResolverMap(): IResolvers {
return {
Admin: {
name(source, args) {
return source.name(args.foo);
}
},
Query: {
viewer(source) {
return queryViewerResolver(source);
}
},
User: {
name(source, args) {
return source.name(args.foo);
}
}
};
}
12 changes: 12 additions & 0 deletions src/tests/presentence/simple.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/** @gqlType */
type Query = unknown;

/** @gqlField */
export function greeting(_: Query): string {
return "Hello, world!";
}

export const operation = `
query {
greeting
}`;
30 changes: 30 additions & 0 deletions src/tests/presentence/simple.ts.expected
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
-----------------
INPUT
-----------------
/** @gqlType */
type Query = unknown;

/** @gqlField */
export function greeting(_: Query): string {
return "Hello, world!";
}

export const operation = `
query {
greeting
}`;

-----------------
OUTPUT
-----------------
import { IResolvers } from "@graphql-tools/utils";
import { greeting as queryGreetingResolver } from "./src/tests/presentence/simple";
export function getResolverMap(): IResolvers {
return {
Query: {
greeting(source) {
return queryGreetingResolver(source);
}
}
};
}
Loading