objection-graphql-resolver

objection.js plugin: resolve GraphQL query using withGraphFetched

Usage no npm install needed!

<script type="module">
  import objectionGraphqlResolver from 'https://cdn.skypack.dev/objection-graphql-resolver';
</script>

README

objection-graphql-resolver

A helper library to resolve GraphQL queries directly with Objection.js models and relations.

  • Highly effective: selects only requested fields and relations (using fine-tuned withGraphFetched)
  • Support unlimited nested resolvers (traversing relationMappings)
  • Support pagination
  • Support virtual attributes
  • Support filters like { date: "2020-10-01", category__in: ["News", "Politics"] }
  • Hook into subqueries with query modifiers
  • Hook into field results to restrict access to sensitive information

History

Before 3.0.0, this library used to be named objection-fetch-graphql.

Install

yarn add objection-graphql-resolver

Minimal all-in-one example

Run GraphQL server:

// Everything is put into a single file for demonstration purposes.
//
// In real projects, you will want to separate models, typedefs,
// model resolvers, and the server into their own modules.

import { ApolloServer } from "apollo-server"
import gql from "graphql-tag"
import { Model } from "objection"
import { GraphResolver, ModelResolver } from "objection-graphql-resolver"

class PostModel extends Model {
  static tableName = "posts"

  declare id: number
  declare text: string
}

const typeDefs = gql`
  type Post {
    id: ID!
    text: String!
  }

  type Query {
    posts: [Post!]!
  }
`

const resolveGraph = GraphResolver({
  // Map GraphQL types to model resolvers
  Post: ModelResolver(PostModel, {
    // List fields that can be accessed via GraphQL
    fields: {
      id: true,
      text: true,
    },
  }),
})

const resolvers = {
  Query: {
    posts: (parent, args, ctx, info) => {
      return resolveGraph(ctx, info, Post.query())
    },
  },
}

new ApolloServer({ typeDefs, resolvers }).listen({ port: 4000 })

Query it with GraphQL client:

import { GraphQLClient } from "graphql-request"
import gql from "graphql-tag"

const client = new GraphQLClient("http://127.0.0.1:4000")

await client.request(
  gql`
    query {
      posts {
        id
        text
      }
    }
  `
)

Relations

Relations will be fetched automatically using withGraphFetched() when resolving nested fields.

Example:

const resolveGraph = GraphResolver({
  User: ModelResolver(UserModel, {
    fields: {
      id: true,
      name: true,
      // will use withGraphFetched("posts")
      // and process subquery with Post model resolver defined below
      posts: true,
    },
  }),
  // No resolver options = access to all fields
  Post: ModelResolver(PostModel),
})
query posts_with_author {
  posts {
    id
    text
    author {
      name
    }
  }
}

query user_with_posts {
  user(id: ID!) {
    name
    posts {
      id
      text
    }
  }
}

More details and examples for relations.

Pagination

Root queries and one-to-many nested relations can be paginated.

Example:

const resolveGraph = GraphResolver({
  User: ModelResolver(UserModel, {
    fields: {
      id: true,
      name: true,
      posts: RelationResolver({
        paginate: CursorPaginage({ take: 10, fields: ["-id"] }),
      }),
    },
  }),
  Post: ModelResolver(PostModel),
})

To paginate root query, use:

const resolvers = {
  Query: {
    posts: async (parent, args, ctx, info) => {
      const page = await resolveGraph(ctx, info, Post.query(), {
        paginate: CursorPaginator({ take: 10, fields: ["-id"] }),
      })
      return page
    },
  },
}

More details and examples for pagination.

Filters

Both root and nested queries can be filtered with GraphQL arguments:

query {
  posts(filter: { date: "2020-10-01", author_id__in: [123, 456] }) {
    nodes {
      id
      text
    }
    cursor
  }
}

Filters will run against database fields, or call model modifiers.

To enable filters, use:

const resolveGraph = GraphResolver({
  Post: ModelResolver(PostModel, {
    // enable all filters for all fields
    filter: true,
    // TODO: granular access
    filter: {
      date: true,
      author_id: true,
      published: true, // where published is a model modifier
    },
  }),
})

More details and examples for filters.

Virtual attributes

Virtual attributes (getters on models) can be accessed the same way as database fields:

export class PostModel extends Model {
  declare id: number
  declare title: string

  get url() {
    assert(this.id)
    return `/${this.id}.html`
  }
}
query {
  posts {
    id
    title
    url
  }
}

More details and examples for virtual attributes.

API

The following functions are exported:

import {
  GraphResolver,
  ModuleResolver,
  FieldResolver,
  RelationResolver,
  CursorPaginator,
} from "objection-graphql-resolver"

Arguments reference

const resolveGraph = GraphResolver(
  // Map GraphQL types to model resolvers (required)
  {
    Post: ModelResolver(
      // Required: Objection.js model class
      PostModel,
      // Default: { fields: true }
      {
        // List fields that can be accessed via GraphQL,
        // or true = all fields can be accessed
        fields: {
          // Select field from database
          id: true,
          // Call model getter with this name
          url: true,
          // Descend into relation
          // (related model must be also registered in this graph resolver)
          author: true,
          // Modify query when this field is resolved
          preview: (query) =>
            query.select(raw("substr(text,1,100) as preview")),
          // Same as text: true
          text: FieldResolver(),
          // Custom field resolver
          text2: FieldResolver({
            // Model (database) field, if different from GraphQL field
            modelField: "text",
          }),
          preview2: FieldResolver({
            // Modify query
            select: (query) =>
              query.select(raw("substr(text,1,100) as preview2")),
            // Post-process selected value
            clean(
              // Selected value
              preview,
              // Current instance
              post,
              // Query context
              context
            ) {
              if (preview.length < 100) {
                return preview
              } else {
                return preview + "..."
              }
            },
          }),
          // Select all objects in one-to-many relation
          comments: true,
          comments_page: RelationResolver({
            // Model field, if different from GraphQL field
            modelField: "comments",
            // Paginate subquery in one-to-many relation
            paginate: CursorPaginator(
              // Pagination options
              // Default: { take: 10, fields: ["id"] }
              {
                // How many object to take per page
                take: 10,
                // Which fields to use for ordering
                // Prefix with - for descending sort
                fields: ["name", "-id"],
              }
            ),
            // Enable filters on one-to-many relation
            filters: true,
            // Modify subquery
            modifier: (query) => query.orderBy("id", "desc"),
            // Post-process selected value, see FIeldResolver
            // clean: ...,
          }),
        },
        // Modify all queries to this model
        modifier: (query) => query.orderBy("id", "desc"),
      }
    ),
  },
  // Options (default: empty)
  {
    // Callback: convert RequestContext into query context
    // Default: merge RequestContext into query context as is
    context(ctx) {
      return { userId: ctx.passport.user.id }
    },
  }
)

const resolvers = {
  Query: {
    posts: async (parent, args, context, info) => {
      const page = await resolveGraph(
        // Resolver context (required)
        // Will be merged into query context,
        // possibly converted with GraphResolver's options.context callback
        context,
        // GraphQLResolveInfo object, as passed by GraphQL executor (required)
        info,
        // Root query (required)
        Post.query(),
        // Default: empty
        {
          // Paginator (only works for list queries)
          // Default: resolve list query as is
          paginate: CursorPaginator({ take: 10, fields: ["-id"] }),
          // Enable filters
          filters: true,
        }
      )
      return page
    },
  },
}