From 9d30395065708c7250e9012c37462e870e58edba Mon Sep 17 00:00:00 2001 From: Nicola Marcacci Rossi Date: Mon, 21 Oct 2024 12:12:46 +0200 Subject: [PATCH 1/2] break: Generate code --- src/api/execute.ts | 37 +++++--------- src/bin/gqm/gqm.ts | 2 + src/context.ts | 3 +- src/resolvers/generate.ts | 79 ++++++++++++++++++++++++++++++ src/resolvers/index.ts | 2 +- src/resolvers/resolvers.ts | 57 --------------------- tests/generated/resolvers/index.ts | 41 ++++++++++++++++ tests/utils/server.ts | 5 ++ 8 files changed, 141 insertions(+), 85 deletions(-) create mode 100644 src/resolvers/generate.ts delete mode 100644 src/resolvers/resolvers.ts create mode 100644 tests/generated/resolvers/index.ts diff --git a/src/api/execute.ts b/src/api/execute.ts index a997162..c0954fa 100644 --- a/src/api/execute.ts +++ b/src/api/execute.ts @@ -1,33 +1,23 @@ import { makeExecutableSchema } from '@graphql-tools/schema'; import { IResolvers } from '@graphql-tools/utils'; import { GraphQLResolveInfo, Source, execute as graphqlExecute, parse } from 'graphql'; -import { merge } from 'lodash'; -import { Context, generate, get, getResolvers } from '..'; +import { Context, get } from '..'; export const execute = async ({ - additionalResolvers, + resolvers, + typeDefs, body, - ...ctx + ...contextValue }: { - additionalResolvers?: IResolvers; + typeDefs: string; + resolvers: IResolvers; body: any; -} & Omit) => { - const document = generate(ctx.models); - - const generatedResolvers = getResolvers(ctx.models); - - const schema = makeExecutableSchema({ - typeDefs: document, - resolvers: merge(generatedResolvers, additionalResolvers), - }); - - const contextValue: Context = { - document, - ...ctx, - }; - - const result = await graphqlExecute({ - schema, +} & Omit) => + graphqlExecute({ + schema: makeExecutableSchema({ + typeDefs, + resolvers, + }), document: parse(new Source(body.query, 'GraphQL request')), contextValue, variableValues: body.variables, @@ -38,6 +28,3 @@ export const execute = async ({ return parent[alias ? alias.value : node.name.value]; }, }); - - return result; -}; diff --git a/src/bin/gqm/gqm.ts b/src/bin/gqm/gqm.ts index 1c57972..e8cac0a 100644 --- a/src/bin/gqm/gqm.ts +++ b/src/bin/gqm/gqm.ts @@ -12,6 +12,7 @@ import { getMigrationDate, printSchemaFromModels, } from '../..'; +import { generateResolvers } from '../../resolvers/generate'; import { DateLibrary } from '../../utils/dates'; import { generateGraphqlApiTypes, generateGraphqlClientTypes } from './codegen'; import { parseKnexfile } from './parse-knexfile'; @@ -43,6 +44,7 @@ program writeToFile(`${generatedFolderPath}/db/knex.ts`, generateKnexTables(models)); await generateGraphqlApiTypes(dateLibrary); await generateGraphqlClientTypes(); + writeToFile(`${generatedFolderPath}/resolvers/index.ts`, generateResolvers(models, gqlModule)); }); program diff --git a/src/context.ts b/src/context.ts index 6f85bb5..ff85ae8 100644 --- a/src/context.ts +++ b/src/context.ts @@ -1,4 +1,4 @@ -import { DocumentNode, GraphQLResolveInfo } from 'graphql'; +import { GraphQLResolveInfo } from 'graphql'; import { IncomingMessage } from 'http'; import { Knex } from 'knex'; import { Models } from './models/models'; @@ -15,7 +15,6 @@ export type Context = { now: DateType; timeZone?: string; knex: Knex; - document: DocumentNode; locale: string; locales: string[]; user?: User; diff --git a/src/resolvers/generate.ts b/src/resolvers/generate.ts new file mode 100644 index 0000000..1444430 --- /dev/null +++ b/src/resolvers/generate.ts @@ -0,0 +1,79 @@ +import CodeBlockWriter from 'code-block-writer'; +import { isRootModel, Models, not, typeToField } from '..'; + +export const generateResolvers = (models: Models, gqmModule = '@smartive/graphql-magic') => { + const writer: CodeBlockWriter = new CodeBlockWriter['default']({ + useSingleQuote: true, + indentNumberOfSpaces: 2, + }); + + writer.writeLine(`import { queryResolver, mutationResolver } from '${gqmModule}';`); + writer.blankLine(); + + writer + .write(`export const resolvers = `) + .inlineBlock(() => { + writer + .write('Query: ') + .inlineBlock(() => { + writer.writeLine('me: queryResolver,'); + for (const model of models.entities.filter(({ queriable }) => queriable)) { + writer.writeLine(`${typeToField(model.name)}: queryResolver,`); + } + for (const model of models.entities.filter(({ listQueriable }) => listQueriable)) { + writer.writeLine(`${model.pluralField}: queryResolver,`); + } + }) + .write(',') + .newLine(); + + const mutations = [ + ...models.entities + .filter(not(isRootModel)) + .filter(({ creatable }) => creatable) + .map((model) => () => { + writer.writeLine(`create${model.name}: mutationResolver,`); + }), + ...models.entities + .filter(not(isRootModel)) + .filter(({ updatable }) => updatable) + .map((model) => () => { + writer.writeLine(`update${model.name}: mutationResolver,`); + }), + ...models.entities + .filter(not(isRootModel)) + .filter(({ deletable }) => deletable) + .flatMap((model) => () => { + writer.writeLine(`delete${model.name}: mutationResolver,`); + writer.writeLine(`restore${model.name}: mutationResolver,`); + }), + ]; + + if (mutations) { + writer + .write('Mutation: ') + .inlineBlock(() => { + for (const mutation of mutations) { + mutation(); + } + }) + .write(',') + .newLine(); + } + + for (const model of models.entities.filter(isRootModel)) { + writer + .write(`${model.name}: `) + .inlineBlock(() => { + writer.writeLine('resolveType: ({ TYPE }) => TYPE,'); + }) + .write(',') + .newLine(); + } + }) + .write(';') + .newLine() + .blankLine(); + + return writer.toString(); +}; diff --git a/src/resolvers/index.ts b/src/resolvers/index.ts index b304d8e..885c292 100644 --- a/src/resolvers/index.ts +++ b/src/resolvers/index.ts @@ -2,9 +2,9 @@ export * from './arguments'; export * from './filters'; +export * from './generate'; export * from './mutations'; export * from './node'; export * from './resolver'; -export * from './resolvers'; export * from './selects'; export * from './utils'; diff --git a/src/resolvers/resolvers.ts b/src/resolvers/resolvers.ts deleted file mode 100644 index f25dbad..0000000 --- a/src/resolvers/resolvers.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { Models } from '../models/models'; -import { isRootModel, merge, not, typeToField } from '../models/utils'; -import { mutationResolver } from './mutations'; -import { queryResolver } from './resolver'; - -export const getResolvers = (models: Models) => { - const resolvers: Record = { - Query: merge([ - { - me: queryResolver, - }, - ...models.entities - .filter(({ queriable }) => queriable) - .map((model) => ({ - [typeToField(model.name)]: queryResolver, - })), - ...models.entities - .filter(({ listQueriable }) => listQueriable) - .map((model) => ({ - [model.pluralField]: queryResolver, - })), - ]), - }; - const mutations = [ - ...models.entities - .filter(not(isRootModel)) - .filter(({ creatable }) => creatable) - .map((model) => ({ - [`create${model.name}`]: mutationResolver, - })), - ...models.entities - .filter(not(isRootModel)) - .filter(({ updatable }) => updatable) - .map((model) => ({ - [`update${model.name}`]: mutationResolver, - })), - ...models.entities - .filter(not(isRootModel)) - .filter(({ deletable }) => deletable) - .map((model) => ({ - [`delete${model.name}`]: mutationResolver, - [`restore${model.name}`]: mutationResolver, - })), - ]; - - if (mutations.length) { - resolvers.Mutation = merge(mutations); - } - - for (const model of models.entities.filter(isRootModel)) { - resolvers[model.name] = { - __resolveType: ({ TYPE }) => TYPE, - }; - } - - return resolvers; -}; diff --git a/tests/generated/resolvers/index.ts b/tests/generated/resolvers/index.ts new file mode 100644 index 0000000..a9dcdbe --- /dev/null +++ b/tests/generated/resolvers/index.ts @@ -0,0 +1,41 @@ +import { mutationResolver, queryResolver } from '../../../src'; + +export const resolvers = { + Query: { + me: queryResolver, + someObject: queryResolver, + reaction: queryResolver, + review: queryResolver, + question: queryResolver, + answer: queryResolver, + anotherObjects: queryResolver, + manyObjects: queryResolver, + reactions: queryResolver, + reviews: queryResolver, + questions: queryResolver, + answers: queryResolver, + }, + Mutation: { + createSomeObject: mutationResolver, + createReview: mutationResolver, + createQuestion: mutationResolver, + createAnswer: mutationResolver, + updateSomeObject: mutationResolver, + updateReview: mutationResolver, + updateQuestion: mutationResolver, + updateAnswer: mutationResolver, + deleteAnotherObject: mutationResolver, + restoreAnotherObject: mutationResolver, + deleteSomeObject: mutationResolver, + restoreSomeObject: mutationResolver, + deleteReview: mutationResolver, + restoreReview: mutationResolver, + deleteQuestion: mutationResolver, + restoreQuestion: mutationResolver, + deleteAnswer: mutationResolver, + restoreAnswer: mutationResolver, + }, + Reaction: { + resolveType: ({ TYPE }) => TYPE, + }, +}; diff --git a/tests/utils/server.ts b/tests/utils/server.ts index ad72650..4246a0b 100644 --- a/tests/utils/server.ts +++ b/tests/utils/server.ts @@ -1,9 +1,11 @@ +import { readFileSync } from 'fs'; import { TypedQueryDocumentNode } from 'graphql'; import graphqlRequest, { RequestDocument, Variables } from 'graphql-request'; import { RequestListener, createServer } from 'http'; import { Knex } from 'knex'; import { up } from '../../migrations/20230912185644_setup'; import { execute } from '../../src'; +import { resolvers } from '../generated/resolvers'; import { getKnex } from './database/knex'; import { ADMIN_ID, setupSeed } from './database/seed'; import { models, permissions } from './models'; @@ -50,7 +52,10 @@ export const withServer = async ( res(JSON.parse(Buffer.concat(chunks).toString())); }); }); + const result = await execute({ + typeDefs: readFileSync('tests/generated/schema.graphql', 'utf8'), + resolvers, req, knex, locale: 'en', From 4f6060b6b8375d2f49509b1d06304ce8f1319f53 Mon Sep 17 00:00:00 2001 From: Nicola Marcacci Rossi Date: Tue, 22 Oct 2024 10:02:15 +0200 Subject: [PATCH 2/2] break: Generate code --- .eslintrc | 4 +- .gqmrc.json | 4 +- docs/docs/1-tutorial.md | 305 ++-- docs/docs/7-graphql-client.md | 148 +- package.json | 2 +- src/api/execute.ts | 30 - src/api/index.ts | 3 - src/bin/gqm/gqm.ts | 8 +- src/bin/gqm/settings.ts | 2 +- src/bin/gqm/templates.ts | 2 +- src/client/gql.ts | 7 - src/client/index.ts | 3 - src/client/models.ts | 1 - src/client/mutations.ts | 93 +- src/client/queries.ts | 225 --- src/context.ts | 5 +- src/db/generate.ts | 15 +- src/index.ts | 1 - src/migrations/generate.ts | 15 +- src/models/models.ts | 18 +- src/models/utils.ts | 81 +- src/permissions/check.ts | 3 +- src/resolvers/arguments.ts | 2 +- src/resolvers/generate.ts | 683 ++++++++- src/resolvers/index.ts | 1 - src/resolvers/mutations.ts | 384 ----- src/resolvers/node.ts | 3 +- src/resolvers/resolver.ts | 5 +- src/resolvers/utils.ts | 2 +- src/schema/generate.ts | 14 +- src/utils/getters.ts | 46 + src/utils/index.ts | 1 + .../__snapshots__/inheritance.spec.ts.snap | 4 +- tests/api/delete.spec.ts | 2 +- tests/api/inheritance.spec.ts | 2 +- tests/api/query.spec.ts | 2 +- tests/generated/api/index.ts | 297 ++-- tests/generated/client/index.ts | 362 +++-- tests/generated/client/mutations.ts | 95 +- tests/generated/db/index.ts | 458 +++--- tests/generated/db/knex.ts | 40 +- tests/generated/resolvers/index.ts | 1249 ++++++++++++++++- tests/generated/schema/index.ts | 389 +++++ tests/unit/__snapshots__/queries.spec.ts.snap | 10 - tests/unit/__snapshots__/resolve.spec.ts.snap | 43 - tests/unit/queries.spec.ts | 11 - tests/unit/resolve.spec.ts | 8 - tests/utils/server.ts | 30 +- 48 files changed, 3449 insertions(+), 1669 deletions(-) delete mode 100644 src/api/execute.ts delete mode 100644 src/api/index.ts delete mode 100644 src/client/gql.ts delete mode 100644 src/client/models.ts delete mode 100644 src/client/queries.ts delete mode 100644 src/resolvers/mutations.ts create mode 100644 src/utils/getters.ts create mode 100644 tests/generated/schema/index.ts delete mode 100644 tests/unit/__snapshots__/queries.spec.ts.snap delete mode 100644 tests/unit/__snapshots__/resolve.spec.ts.snap delete mode 100644 tests/unit/queries.spec.ts delete mode 100644 tests/unit/resolve.spec.ts diff --git a/.eslintrc b/.eslintrc index c471e81..4e74490 100644 --- a/.eslintrc +++ b/.eslintrc @@ -5,8 +5,10 @@ "project": "./tsconfig.eslint.json" }, "rules": { + "@typescript-eslint/ban-types": "off", + "@typescript-eslint/no-namespace": "off", "@typescript-eslint/no-explicit-any": "off", - "@typescript-eslint/no-floating-promises": ["error"], + "@typescript-eslint/no-floating-promises": "error", "no-constant-binary-expression": "error", "no-console": ["error", { "allow": ["info", "warn", "error", "trace", "time", "timeEnd"] }] } diff --git a/.gqmrc.json b/.gqmrc.json index 734d96e..3307971 100644 --- a/.gqmrc.json +++ b/.gqmrc.json @@ -2,7 +2,7 @@ "modelsPath": "tests/utils/models.ts", "generatedFolderPath": "tests/generated", "graphqlQueriesPath": "tests", - "gqlModule": "../../../src", + "gqmModule": "../../../src", "knexfilePath": "knexfile.ts", "dateLibrary": "luxon" -} \ No newline at end of file +} diff --git a/docs/docs/1-tutorial.md b/docs/docs/1-tutorial.md index 7bae569..a23cf20 100644 --- a/docs/docs/1-tutorial.md +++ b/docs/docs/1-tutorial.md @@ -21,47 +21,52 @@ Replace `src/app/globals.css`: @tailwind utilities; main { - @apply w-96 mx-auto + @apply w-96 mx-auto; } nav { - @apply flex items-center + @apply flex items-center; } -h1, h2, h3, h4 { - @apply font-bold +h1, +h2, +h3, +h4 { + @apply font-bold; } h1 { - @apply text-4xl mb-4 flex-grow + @apply text-4xl mb-4 flex-grow; } h2 { - @apply text-3xl mb-3 + @apply text-3xl mb-3; } h3 { - @apply text-2xl mb-2 + @apply text-2xl mb-2; } h4 { - @apply text-xl mb-1 + @apply text-xl mb-1; } a { - @apply text-blue-500 + @apply text-blue-500; } -article, form { - @apply mb-4 p-3 rounded-lg shadow-md border border-gray-100 +article, +form { + @apply mb-4 p-3 rounded-lg shadow-md border border-gray-100; } -input, textarea { - @apply border border-gray-300 w-full rounded-md p-1 +input, +textarea { + @apply border border-gray-300 w-full rounded-md p-1; } label span { - @apply font-bold + @apply font-bold; } ``` @@ -69,11 +74,13 @@ Replace `src/app/page.tsx`: ```tsx export default async function Home() { - return
+ return ( +
+ ); } ``` @@ -89,9 +96,9 @@ Add this setting to `next.config.mjs`: ```ts const nextConfig = { - experimental: { - serverComponentsExternalPackages: ['knex'], - } + experimental: { + serverComponentsExternalPackages: ['knex'], + }, }; ``` @@ -137,7 +144,6 @@ npx gqm generate-migration Enter a migration name, e.g. "setup". - Run the migration ```bash @@ -157,36 +163,38 @@ import { getSession } from '@auth0/nextjs-auth0'; export default async function Page() { const session = await getSession(); - return
- -
+ return ( +
+ +
+ ); } ``` It should now be possible for you to log in and out again. -### Account setup +### Account setup Now, we need to ensure that the user is stored in the database. First extend the user model in `src/config/models.ts` with the following fields: ```tsx - fields: [ - { - name: 'authId', - type: 'String', - nonNull: true, - }, - { - name: 'username', - type: 'String', - nonNull: true - } - ] +fields: [ + { + name: 'authId', + type: 'String', + nonNull: true, + }, + { + name: 'username', + type: 'String', + nonNull: true, + }, +]; ``` The models have changed, generate the new types: @@ -210,28 +218,28 @@ npx env-cmd knex migrate:latest Now let's implement the `// TODO: get user` part in the `src/graphql/execute.ts` file ```ts - const session = await getSession(); - if (session) { - let dbUser = await db('User').where({ authId: session.user.sub }).first(); - if (!user) { - await db('User').insert({ - id: randomUUID(), - authId: session.user.sub, - username: session.user.nickname - }) - dbUser = await db('User').where({ authId: session.user.sub }).first(); - } - user = { - ...dbUser!, - role: 'ADMIN' - } +const session = await getSession(); +if (session) { + let dbUser = await db('User').where({ authId: session.user.sub }).first(); + if (!user) { + await db('User').insert({ + id: randomUUID(), + authId: session.user.sub, + username: session.user.nickname, + }); + dbUser = await db('User').where({ authId: session.user.sub }).first(); } + user = { + ...dbUser!, + role: 'ADMIN', + }; +} ``` Extend `src/graphql/client/queries/get-me.ts` to also fetch the user's username: ```ts -import { gql } from '@smartive/graphql-magic'; +import { gql } from 'graphql-request'; export const GET_ME = gql` query GetMe { @@ -252,18 +260,26 @@ npx gqm generate Now, let's modify `src/app/page.tsx` so that it fetches the user from the database: ```tsx -import { GetMeQuery } from "@/generated/client"; -import { GET_ME } from "@/graphql/client/queries/get-me"; -import { executeGraphql } from "@/graphql/execute"; +import { GetMeQuery } from '@/generated/client'; +import { GET_ME } from '@/graphql/client/queries/get-me'; +import { executeGraphql } from '@/graphql/execute'; export default async function Home() { - const { data: { me } } = await executeGraphql({ query: GET_ME }); + const { + data: { me }, + } = await executeGraphql({ query: GET_ME }); return (
); @@ -334,24 +350,24 @@ npx env-cmd knex migrate:latest Create a new query `src/graphql/client/queries/get-posts.ts`: ```ts -import { gql } from '@smartive/graphql-magic'; +import { gql } from 'graphql-request'; export const GET_POSTS = gql` query GetPosts { posts { + id + title + content + createdBy { + username + } + comments { id - title - content createdBy { - username - } - comments { - id - createdBy { - username - } - content + username } + content + } } } `; @@ -365,23 +381,37 @@ npx gqm generate Now add all the logic to create and display posts and comments to `src/app/page.tsx` - ```tsx -import { CreateCommentMutationMutation, CreateCommentMutationMutationVariables, CreatePostMutationMutation, CreatePostMutationMutationVariables, GetMeQuery, GetPostsQuery } from "@/generated/client"; -import { CREATE_COMMENT, CREATE_POST } from "@/generated/client/mutations"; -import { GET_ME } from "@/graphql/client/queries/get-me"; -import { GET_POSTS } from "@/graphql/client/queries/get-posts"; -import { executeGraphql } from "@/graphql/execute"; -import { revalidatePath } from "next/cache"; +import { + CreateCommentMutationMutation, + CreateCommentMutationMutationVariables, + CreatePostMutationMutation, + CreatePostMutationMutationVariables, + GetMeQuery, + GetPostsQuery, +} from '@/generated/client'; +import { CREATE_COMMENT, CREATE_POST } from '@/generated/client/mutations'; +import { GET_ME } from '@/graphql/client/queries/get-me'; +import { GET_POSTS } from '@/graphql/client/queries/get-posts'; +import { executeGraphql } from '@/graphql/execute'; +import { revalidatePath } from 'next/cache'; export default async function Home() { - const { data: { me } } = await executeGraphql({ query: GET_ME }); + const { + data: { me }, + } = await executeGraphql({ query: GET_ME }); return (
{me && } @@ -390,81 +420,92 @@ export default async function Home() { } async function Posts({ me }: { me: GetMeQuery['me'] }) { - const { data: { posts } } = await executeGraphql({ query: GET_POSTS }) - - return
- {posts.map(post =>
-
-

{post.title}

-
by {post.createdBy.username}
-

{post.content}

-

Comments

- {post.comments.map(comment => (
-
{comment.createdBy.username}
-

{comment.content}

by {comment.createdBy.username} -
) - )} - {me && } -
-
)} -
+ const { + data: { posts }, + } = await executeGraphql({ query: GET_POSTS }); + + return ( +
+ {posts.map((post) => ( +
+
+

{post.title}

+
by {post.createdBy.username}
+

{post.content}

+

Comments

+ {post.comments.map((comment) => ( +
+
{comment.createdBy.username}
+

{comment.content}

by {comment.createdBy.username} +
+ ))} + {me && } +
+
+ ))} +
+ ); } async function CreatePost() { async function createPost(formData: FormData) { - 'use server' + 'use server'; await executeGraphql({ query: CREATE_POST, variables: { data: { title: formData.get('title') as string, - content: formData.get('content') as string - } - } - }) - revalidatePath('/') + content: formData.get('content') as string, + }, + }, + }); + revalidatePath('/'); } - return
-

New Post

- -