Generates automatically an executable schema out of your Sequelize models


Keywords
graphql, schema, schema generator, generate schema, generate resolvers, sequelize graphql, graphql typescript, gene, lookahead, performance, best practices
License
MIT
Install
npm install @graphql-gene/plugin-sequelize@1.2.2

Documentation

GraphQL Gene

TypeScript npm version npm downloads License

Use graphql-gene to generate automatically an executable schema out of your ORM models. Everything is fully typed and define once for both GraphQL and Typescript types. See Highlights section for more.


❤️ Provided by Accès Impôt's engineering team

Accès Impôt
🇨🇦 Online tax declaration service 🇨🇦

Table of contents


Highlights

  • ⚡️ Performant - Automatically avoid querying nested database relationships if they are not requested.
  • 🔒 Secure - Easily create and share directives at the type or field level (i.e. @userAuth).
  • ⏰ Time-to-delivery - No time wasted writing similar resolvers.
  • 🧩 Resolver template - Generates the resolver for you with deep where argument and more.
  • Type safe - Resolver arguments and return value are deeply typed.
  • 🎯 One source of truth - Types are defined once and shared between GraphQL and Typescript.
  • 💥 Works with anything - New or existing projects. Works with any GraphQL servers, ORM, or external sources.
  • 🔌 Plugins - Simple plugin system to potentially support any Node.js ORM. See Writing a Plugin.

Quick Setup

Install graphql-gene with the plugin you need for your ORM:

# pnpm
pnpm add graphql-gene @graphql-gene/plugin-sequelize

# yarn
yarn add graphql-gene @graphql-gene/plugin-sequelize

# npm
npm i graphql-gene @graphql-gene/plugin-sequelize

Export all models from one file

Create a file where you export all your GraphQL types including your database models, but also basic GraphQL types, inputs, enums.

src/models/graphqlTypes.ts

import { defineEnum, defineType } from 'graphql-gene'

// All your ORM models
export * from './models'

// i.e. some basic GraphQL types
export const MessageOutput = defineType({
  type: 'MessageTypeEnum!',
  text: 'String!',
})
export const MessageTypeEnum = defineEnum(['info', 'success', 'warning', 'error'])

// i.e. assuming AuthenticatedUser is defined as alias in User.geneConfig
export { User as AuthenticatedUser, MutationLoginOutput } from '../models/User/User.model'

Typing

You can now create a declaration file to define the GeneContext and GeneSchema types used by graphql-gene. You need to use the GeneTypesToTypescript utility to type every GraphQL types in GeneSchema.

You can also extend the context based on the GraphQL server you're using (optional).

src/types/graphql-gene.d.ts

import type { GeneTypesToTypescript } from 'graphql-gene'
import type { YogaInitialContext } from 'graphql-yoga'
import * as graphqlTypes from '../models/graphqlTypes'

declare module 'graphql-gene/schema' {
  export interface GeneSchema extends GeneTypesToTypescript<typeof graphqlTypes> {
    Query: object
    Mutation: object
  }
}

declare module 'graphql-gene/context' {
  export interface GeneContext extends YogaInitialContext {}
}

Generate the schema

The last step is to call generateSchema and pass the returned schema to your GraphQL server. You simply have to pass all types imported from graphqlTypes.ts as shown in the example below.

Please note that graphql-gene is using Date, DateTime, or JSON for certain data types. If you don't provide scalars for them, they will fallback to the String type.

You can use the resolvers option to provide the scalars as it accepts both resolvers and scalar objects (resolvers?: { [field: string]: GraphQLFieldResolver } | GraphQLScalarType).

If you follow the example below, you will also need to install graphql-scalars.

src/server/schema.ts

import { DateResolver, DateTimeResolver, JSONResolver } from 'graphql-scalars'
import { generateSchema } from 'graphql-gene'
import { pluginSequelize } from '@graphql-gene/plugin-sequelize'
import * as graphqlTypes from '../models/graphqlTypes'

const {
  typeDefs,
  resolvers,
  schema,
  schemaString,
  schemaHtml,
} = generateSchema({
  resolvers: {
    Date: DateResolver,
    DateTime: DateTimeResolver,
    JSON: JSONResolver,
  },
  plugins: [pluginSequelize()],
  types: graphqlTypes,
})

export { typeDefs, resolvers, schema, schemaString, schemaHtml }

The schema returned is an executable schema so you can simply pass it to your GraphQL server:

src/server/index.ts

import { createServer } from 'node:http'
import { createYoga } from 'graphql-yoga'
import { schema } from './schema'

const yoga = createYoga({ schema })
const server = createServer(yoga)

server.listen(4000, () => {
  console.info('Server is running on http://localhost:4000/graphql')
})

You can also pass typeDefs and resolvers to a function provided by your GraphQL server to create the schema:

import { createServer } from 'node:http'
import { createSchema, createYoga } from 'graphql-yoga'
import { typeDefs, resolvers } from './schema'

const schema = createSchema({ typeDefs, resolvers })
const yoga = createYoga({ schema })
const server = createServer(yoga)

server.listen(4000, () => {
  console.info('Server is running on http://localhost:4000/graphql')
})

Allow inspecting the generated schema

You can look at the schema in graphql language using schemaString and schemaHtml returned by generateSchema.

  • schemaString: you can generate a file like schema.gql that you add to .gitignore then use it to inspect the schema in your code editor.
  • schemaHtml: you can add a HTML endpoint like /schema and respond with schemaHtml. The HTML comes with syntax highlighting provided by unpkg.com and highlight.js.

Here's an example using Fastify:

src/server/index.ts

import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import fastify from 'fastify'
import { schema, schemaString, schemaHtml } from './schema'

//
// Your GraphQL server code
//

const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)

const app = fastify({ logger: true })

if (process.env.NODE_ENV !== 'production') {
  // Expose schema as HTML page with graphql syntax highlighting
  app.get('/schema', (_, reply) => reply.type('text/html').send(schemaHtml))

  // Generate a .gql file locally (no need to await)
  fs.promises.writeFile(
    path.resolve(__dirname, '../../schema.gql'),
    schemaString
  )
}
375269573-093fa556-9b80-4ad2-9cea-a8f312999293

Query filtering

No need to write Query resolvers anymore! You can simply use the filter arguments that are automatically defined when using the default resolver at the Query level. The same filter arguments are also defined on all association fields so you can also query with nested filters.

Default resolver

import { Model } from 'sequelize'
import { extendTypes } from 'graphql-gene'

export class Product extends Model {
  // ...
}

extendTypes({
  Query: {
    products: {
      resolver: 'default',
      returnType: '[Product!]',
    },
  },
})
query productsByColor($color: String) {
  products(where: { color: { eq: $color } }, order: [name_ASC]) {
    id
    name
    color

    # Association fields also have filters
    variants(where: { size: { in: ["US 10", "US 11"] } }) {
      id
      size
    }
  }
}

Filter arguments

Argument Description
id String - Entry id (only available for fields returning a single entry).
page Int - Page number for query pagination. Default: 1.
perPage Int - Amount of results per page. Default: 10.
where Record<Attribute, Record<Operator, T>> - Where options generated based on the fields of the return type (i.e. where: { name: { eq: "Foo" } }).
where Record<Attribute, Record<Operator, T>> - Where options generated based on the fields of the return type (i.e. where: { name: { eq: "Foo" } }).
order [foo_ASC] - Array of enum values representing the order in which the results should be sorted. The enum values are defined based on the attribute name + _ASC or _DESC (i.e. order: [name_ASC, foo_DESC]).

Operators

Generic operators

Operator Description
eq T - The value equals to...
ne T - The value does not equals to...
in [T] - The value is in...
notIn [T] - The value is not in...
null Boolean - The value is null if true. The value is not null if false.
and [CurrentWhereOptionsInput!] - Array of object including the same operators. It represents a set of and conditions.
or [CurrentWhereOptionsInput!] - Array of object including the same operators. It represents a set of or conditions.

String operators

Operator Description
lt String - The value is like... (i.e. { like: "%foo%" })
lte String - The value is not like... (i.e. { notLike: "%foo%" })

Date and number operators

Operator Description
lt T - The value is less than...
lte T - The value is less than or equal to...
gt T - The value is greater than...
gte T - The value is greater than or equal to...


Gene config

By default, if a model is part of the types provided to generateSchema, it will be added to your schema.

Nevertheless, you might need to exclude some fields like password, define queries or mutations. You can set GraphQL-specific configuration by adding a static readonly geneConfig object to your model (more examples below) or use extendTypes to add fields to Query/Mutation.

import { Model } from 'sequelize'
import { defineGraphqlGeneConfig, extendTypes } from 'graphql-gene'

export class User extends Model {
  // ...

  static readonly geneConfig = defineGraphqlGeneConfig(User, {
    // Your config
  })
}

extendTypes({
  Query: {
    foo: {
      // ...
    },
  },
})

Options

Name Description
include (InferFields<M> | RegExp)[] - Array of fields to include in the GraphQL type. Default: all included.
exclude (InferFields<M> | RegExp)[] - Array of fields to exclude in the GraphQL type. Default: ['createdAt', updatedAt'].
includeTimestamps boolean | ('createdAt' | 'updatedAt')[] - Include the timestamp attributes or not. Default: false.
varType GraphQLVarType - The GraphQL variable type to use. Default: 'type'.
directives GeneDirectiveConfig[] - Directives to apply at the type level (also possible at the field level).
aliases Record<GraphqlTypeName], GeneConfig> - The values of "aliases" would be nested GeneConfig properties that overwrites the ones set at a higher level. This is useful for instances with a specific scope include more fields that the parent model (i.e. AuthenticatedUser being an alias of User). Note that the alias needs to be exported from graphqlTypes.ts as well (i.e. export { User as AuthenticatedUser } from '../models/User/User.model').

Define queries/mutations inside your model

src/models/Prospect/Prospect.model.ts

import type { InferAttributes, InferCreationAttributes } from 'sequelize'
import { Model, Table, Column, Unique, AllowNull, DataType } from 'sequelize-typescript'
import { defineEnum, defineType, extendTypes } from 'graphql-gene'
import { isEmail } from '../someUtils.ts'

export
@Table
class Prospect extends Model<InferAttributes<Prospect>, InferCreationAttributes<Prospect>> {
  declare id: CreationOptional<number>

  @Unique
  @AllowNull(false)
  @Column(DataType.STRING)
  declare email: string

  @Column(DataType.STRING)
  declare language: string | null
}

extendTypes({
  Mutation: {
    registerProspect: {
      args: { email: 'String!', locale: 'String' },
      returnType: 'MessageOutput!',

      resolver: async ({ args }) => {
        // `args` type is inferred from the GraphQL definition above
        // { email: string; locale: string | null | undefined }
        const { email, locale } = args

        if (!isEmail(email)) {
          // The return type is deeply inferred from the `MessageOutput`
          // definition. For instance, the `type` value must be:
          // 'info' | 'success' | 'warning' | 'error'
          return { type: 'error' as const, text: 'Invalid email' }
        }
        // No need to await
        Prospect.create({ email, language: locale })
        return { type: 'success' as const }
      },
    },
  },
})

export const MessageOutput = defineType({
  type: 'MessageTypeEnum!',
  text: 'String',
})

export const MessageTypeEnum = defineEnum(['info', 'success', 'warning', 'error'])

Define directives

geneConfig.directives accepts an array of GeneDirectiveConfig which will add the directive at the type level (current model). It is recommended to create directives as factory function using defineDirective for better typing (see example below).

Directives are simply wrappers around resolvers following a middleware pattern. At the type level, it looks across your whole schema and wrap the resolver of fields returning the given type. This way, the field itself returns null on error instead of returning an object with all its fields being null.

type GeneDirectiveConfig<
  TDirectiveArgs =
    | Record<string, string | number | boolean | string[] | number[] | boolean[] | null>
    | undefined,
  TSource = Record<string, unknown> | undefined,
  TContext = GeneContext,
  TArgs = Record<string, unknown> | undefined,
> = {
  name: string
  args?: TDirectiveArgs
  handler: GeneDirectiveHandler<TSource, TContext, TArgs>
}

type GeneDirectiveHandler<TSource, TContext, TArgs, TResult = unknown> = (options: {
  source: Parameters<GraphQLFieldResolver<TSource, TContext, TArgs, TResult>>[0]
  args: Parameters<GraphQLFieldResolver<TSource, TContext, TArgs, TResult>>[1]
  context: Parameters<GraphQLFieldResolver<TSource, TContext, TArgs, TResult>>[2]
  info: Parameters<GraphQLFieldResolver<TSource, TContext, TArgs, TResult>>[3]
  resolve: () => Promise<TResult> | TResult
}) => Promise<void> | void

Example: User authentication directive

src/models/User/userAuthDirective.ts

import { GraphQLError } from 'graphql'
import { defineDirective } from 'graphql-gene'
import { getQueryIncludeOf } from '@graphql-gene/plugin-sequelize'
import { User } from './User.model'
import { getJwtTokenPayload } from './someUtils'

export enum ADMIN_ROLES {
  developer = 'developer',
  manager = 'manager',
  superAdmin = 'superAdmin',
}

declare module 'graphql-gene/context' {
  export interface GeneContext {
    authenticatedUser?: User | null
  }
}

function throwUnauthorized(): never {
  throw new GraphQLError('Unauthorized')
}

/**
 * Factory function returning the directive object
 */
export const userAuthDirective = defineDirective<{
  // Convert ADMIN_ROLES enum to a union type
  roles: `${ADMIN_ROLES}`[]
}>(args => ({
  name: 'userAuth', // only used to add `@userAuth` to the schema in graphql language
  args,

  async handler({ context, info }) {
    // If it was previously set to `null`
    if (context.authenticatedUser === null) return throwUnauthorized()

    const isAuthorized = (user: User | null) =>
      !args.roles.length || args.roles.some(role => user?.adminRole === role)

    if (context.authenticatedUser) {
      if (!isAuthorized(context.authenticatedUser)) throwUnauthorized()

      return // Proceed if user is fetched and authorized
    }

    // i.e. `context.request` coming from Fastify
    const authHeader = context.request.headers.get('authorization')
    const [, token] =
      authHeader?.match(/^Bearer\s+(\S+)$/) || ([] as (string | undefined)[])
    if (!token) return throwUnauthorized()

    // For performance: avoid querying nested associations if they are not requested.
    // `getQueryIncludeOf` look deeply inside the operation (query or mutation) for
    // the `AuthenticatedUser` type in order to know which associations are requested.
    const includeOptions = getQueryIncludeOf(info, 'AuthenticatedUser', {
      // Set to true if the directive is added to a field that is not of type "AuthenticatedUser"
      lookFromOperationRoot: true,
    })

    const { id, email } = getJwtTokenPayload(token) || {}
    if (!id && !email) return throwUnauthorized()

    const user = await User.findOne({ where: { id, email }, ...includeOptions })
    context.authenticatedUser = user

    if (!user || !isAuthorized(user)) throwUnauthorized()
  },
}))

The args option allow you to use it in different contexts:

src/models/User/User.model.ts

import { defineGraphqlGeneConfig, extendTypes } from 'graphql-gene'
import { userAuthDirective } from '.userAuthDirective.ts'

export
@Table
class User extends Model<InferAttributes<User>, InferCreationAttributes<User>> {
  declare id: CreationOptional<number>

  // ...

  static readonly geneConfig = defineGraphqlGeneConfig(User, {
    include: ['id', 'username'],

    aliases: {
      // Use an alias since `AuthenticatedUser` as a quite
      // different scope than a public `User`.
      AuthenticatedUser: {
        include: ['id', 'email', 'username', 'role', 'address', 'orders'],
        // `roles: []` means no specific admin `role` needed
        // The user just needs to be authenticated.
        directives: [userAuthDirective({ roles: [] })],
      },
    },
  })
}

extendTypes({
  Query: {
    me: {
      returnType: 'AuthenticatedUser',
      // `context.authenticatedUser` is defined in `userAuthDirective`
      resolver: ({ context }) => context.authenticatedUser,
    },
  },
})

The alias needs to be exported as well from graphqlTypes.ts:

src/models/graphqlTypes.ts

export * from './models'

// Export the alias for typing
export { User as AuthenticatedUser } from '../models/User/User.model'

Another example for superAdmin role:

src/models/AdminAccount/AdminAccount.model.ts

static readonly geneConfig = defineGraphqlGeneConfig(AdminAccount, {
  // i.e. Only allow super admin users to access the `AdminAccount` data
  directives: [userAuthDirective({ roles: ['superAdmin'] })],
})

Sending the request

This is how the response would look like for Query.me if the token is missing or invalid:

type Query {
  me: AuthenticatedUser
}
image

Available plugins



Contribution

Local development
# Install dependencies
pnpm install
#
# or if you're having issues on Apple M Chips:
# arch -arm64 pnpm install -f

# Develop
pnpm playground: dev

# Run ESLint
pnpm lint

# Run Vitest
pnpm test

# Run Vitest in watch mode
pnpm test:watch