Skip to content

Commit

Permalink
Merge pull request #118 from rocket-connect/feat/subgraphs
Browse files Browse the repository at this point in the history
feat: add subgraph support
  • Loading branch information
danstarns authored Nov 2, 2024
2 parents f08b8ab + 85503bd commit 57cacf3
Show file tree
Hide file tree
Showing 5 changed files with 835 additions and 22 deletions.
2 changes: 1 addition & 1 deletion apps/docs/docusaurus.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ const config: Config = {
locales: ["en"],
},
customFields: {
API_URL: process.env.API_URL,
API_URL: process.env.API_URL || "",
},
plugins: [tailwindPlugin],
presets: [
Expand Down
81 changes: 75 additions & 6 deletions packages/gqlpt/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ import {
import { sortAST } from "@apollo/utils.sortast";
import { promises } from "fs";
import {
DocumentNode,
GraphQLSchema,
SelectionNode,
buildClientSchema,
buildSchema,
graphql,
Expand Down Expand Up @@ -38,6 +40,7 @@ export interface GQLPTClientOptions {
adapter: Adapter;
generatedPath?: string;
maxRetries?: number;
excludedQueries?: string[];
}

type MergedTypeMap = GeneratedTypeMap & DefaultTypeMap;
Expand Down Expand Up @@ -273,13 +276,13 @@ export class GQLPTClient<T extends MergedTypeMap = MergedTypeMap> {
Failed with the following validation errors:
${this.validateQueryAgainstSchema(currentQuery!).join("\n")}
Please fix these validation errors and provide an updated query.
${this.options.excludedQueries?.length ? `Note: The following queries cannot be used: ${this.options.excludedQueries.join(", ")}` : ""}
Respond with only a JSON object containing the corrected query and variables:
{
"query": "the corrected query",
"variables": { optional variables object }
}
Please fix these validation errors.
${QUERY_GENERATION_RULES}
${QUERY_JSON_RESPONSE_FORMAT}
`
: `
Given the following GraphQL schema:
Expand All @@ -304,6 +307,24 @@ export class GQLPTClient<T extends MergedTypeMap = MergedTypeMap> {
};

const queryAst = parse(result.query, { noLocation: true });
const excludedErrors = this.validateExcludedQuery(queryAst);

if (excludedErrors.length > 0) {
if (retryCount >= (this.options.maxRetries || 5)) {
throw new Error(
`Could not generate valid query after ${retryCount} attempts. Last errors: ${excludedErrors.join(", ")}`,
);
}

return this.generateQueryWithRetry(
plainText,
schema,
retryCount + 1,
response.conversationId,
result.query,
);
}

const newAst = clearOperationNames(queryAst);
const sortedAst = sortAST(newAst);
const generatedQuery = print(sortedAst);
Expand Down Expand Up @@ -351,4 +372,52 @@ export class GQLPTClient<T extends MergedTypeMap = MergedTypeMap> {
return [(error as Error).message];
}
}

private validateSelectionSet(
selections: ReadonlyArray<SelectionNode>,
operationType: string,
errors: string[],
): void {
for (const selection of selections) {
if (selection.kind === "Field") {
const fieldName = selection.name.value;
if (this.options.excludedQueries?.includes(fieldName)) {
errors.push(
`${operationType} contains excluded field '${fieldName}'. This field cannot be used.`,
);
}

if (selection.selectionSet?.selections) {
this.validateSelectionSet(
selection.selectionSet.selections,
operationType,
errors,
);
}
}
}
}

private validateExcludedQuery(queryDoc: DocumentNode): string[] {
if (!this.options.excludedQueries?.length) {
return [];
}

const errors: string[] = [];

for (const def of queryDoc.definitions) {
if (def.kind === "OperationDefinition") {
const operationType = def.operation;
if (def.selectionSet?.selections) {
this.validateSelectionSet(
def.selectionSet.selections,
operationType,
errors,
);
}
}
}

return errors;
}
}
24 changes: 20 additions & 4 deletions packages/gqlpt/src/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,17 @@ Rules for generating the GraphQL query:
- Start with the operation type (query, mutation, or subscription).
- Do not include an operation name.
- Declare all GraphQL variables at the top of the query.
- If the schema has subgraphs (e.g., swapi_subgraph), include them as the first level field.
2. Fields:
- Only include fields that are explicitly requested or necessary for the query, however, if a 'id' field is available, it should be included.
- If the type has 'id' as a field, always include it.
- Only include non 'id' fields that are explicitly requested or necessary for the query.
- For nested objects, only traverse if specifically asked or crucial for the query.
- When accessing subgraph fields, navigate through the subgraph type first.
- For connection types (e.g., SpeciesConnection), always include the field that contains the actual data array.
- Examples:
- With subgraph: { swapi_subgraph { allSpecies { species { id name } } } }
- Without subgraph: { users { id name } }
3. Arguments and Input Types:
- Always check if there's a defined input type for arguments.
Expand All @@ -19,6 +26,8 @@ Rules for generating the GraphQL query:
Use: query($where: UserWhereInput) { users(where: $where) { ... } }
- If no input type is defined: user(id: ID!): User
Use: query($id: ID!) { user(id: $id) { ... } }
- If within subgraph: allSpecies(first: Int): SpeciesConnection
Use: query($first: Int) { swapi_subgraph { allSpecies(first: $first) { species { ... } } } }
4. Variables:
- Define all variables used in the query with their correct types.
Expand All @@ -32,9 +41,16 @@ Rules for generating the GraphQL query:
- Only use fragments if explicitly requested or if it significantly improves query readability.
7. Always prefer input types when available:
- Correct: query($where: UserWhereInput) { users(where: $where) { id name } }
- Incorrect: query($name: String) { users(where: { name: $name }) { id name } }
- Ensure that if the input type is required in the schema, it is also required in the query.
- Correct: query($where: UserWhereInput) { users(where: $where) { id name } }
- Incorrect: query($name: String) { users(where: { name: $name }) { id name } }
- Ensure that if the input type is required in the schema using !, it is also required in the query, else make it optional.
8. Subgraph and Connection Patterns:
- Always check if fields belong to a subgraph and include the subgraph field when needed.
- For connection types, traverse to the actual data array (e.g., 'species' field in SpeciesConnection).
- Example:
Correct: query($first: Int) { swapi_subgraph { allSpecies(first: $first) { species { id name } } } }
Incorrect: query($first: Int) { allSpecies(first: $first) { edges { node { id name } } } }
`;

export const TYPE_GENERATION_RULES = `
Expand Down
61 changes: 50 additions & 11 deletions packages/gqlpt/tests/GQLPTClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,13 @@ adapters.forEach(({ name, adapter }) => {
});

test("should connect to the server", async () => {
const typeDefs = `
const typeDefs = /* GraphQL */ `
type User {
id: ID!
name: String!
email: String!
}
type Query {
users(name: String): [User!]!
}
Expand All @@ -55,7 +55,7 @@ adapters.forEach(({ name, adapter }) => {
});

test("should return complex graphql query", async () => {
const typeDefs = `
const typeDefs = /* GraphQL */ `
type User {
id: ID!
name: String!
Expand All @@ -68,7 +68,7 @@ adapters.forEach(({ name, adapter }) => {
title: String!
body: String!
}
input UserWhereInput {
name: String
}
Expand All @@ -87,7 +87,7 @@ adapters.forEach(({ name, adapter }) => {

assertMatchesVariation(result, [
{
query: `
query: /* GraphQL */ `
query ($where: UserWhereInput) {
users(where: $where) {
email
Expand All @@ -105,17 +105,56 @@ adapters.forEach(({ name, adapter }) => {
where: { name: "dan" },
},
},
{
query: /* GraphQL */ `
query ($name: String) {
users(where: { name: $name }) {
email
id
name
posts {
body
id
title
}
}
}
`,
variables: {
name: "dan",
},
},
{
query: /* GraphQL */ `
query ($where: UserWhereInput) {
users(where: $where) {
email
id
name
posts {
id
title
}
}
}
`,
variables: {
where: {
name: "dan",
},
},
},
]);
});

test("should generate mutation", async () => {
const typeDefs = `
const typeDefs = /* GraphQL */ `
type User {
id: ID!
name: String!
email: String!
}
input FriendInput {
name: String!
}
Expand Down Expand Up @@ -148,7 +187,7 @@ adapters.forEach(({ name, adapter }) => {

assertMatchesVariation(result, [
{
query: `
query: /* GraphQL */ `
mutation ($input: CreateUserInput!) {
createUser(input: $input) {
email
Expand All @@ -168,15 +207,15 @@ adapters.forEach(({ name, adapter }) => {
});

test("should return fields in alphabetical order", async () => {
const typeDefs = `
const typeDefs = /* GraphQL */ `
type User {
zebra: String!
apple: String!
monkey: String!
banana: String!
cat: String!
}
type Query {
user: User!
}
Expand All @@ -190,7 +229,7 @@ adapters.forEach(({ name, adapter }) => {
);

expect(parsePrint(query)).toBe(
parsePrint(`
parsePrint(/* GraphQL */ `
query {
user {
apple
Expand Down
Loading

0 comments on commit 57cacf3

Please sign in to comment.