Migrating custom plugins
If you've written some PostGraphile V4 plugins by hand (not using one of the
make...Plugin
helpers) then this migration guide is for you. We'll step you
through some of the key changes.
Overview
Here's the rough process:
- Ensure you have tests and that they pass
- Move all
require()
calls to the top (not inside of functions) - Convert the plugin to TypeScript
- Convert your tests to TypeScript (or use
// @ts-check
) and fix errors - Ensure your tests still pass
- Upgrade to V5 dependencies
- Perform basic code transforms
- Turn on strict typing
- Fix the TypeScript errors
- Ensure your tests still pass
- Make your plugin exportable (optional)
Convert the plugin to TypeScript
(You can read why converting to TypeScript is so important in the TypeScript section below.)
We recommend that you extend from the @tsconfig/node18
preset; you can disable noImplicitAny
to massively reduce the number of TypeScript errors you need to deal with:
{
"extends": "@tsconfig/node18/tsconfig.json",
"compilerOptions": {
// You may want to enable this flag in V4 since V4 types were extremely
// loose (since it was originally written in Flow, not TypeScript):
//
// "noImplicitAny": false,
// If your tests are written in JS you'll need this for subpath importing
// to work:
"allowJs": true,
"rootDir": "./src",
"outDir": "./dist",
"declarationDir": "dist",
"declaration": true,
"sourceMap": true
},
"exclude": ["node_modules"]
}
The main thing is to make it so we're using TypeScript syntax, that TypeScript compiles, and that our tests run the compiled code. Once we've ported to V5 we'll make the types a lot stricter.
Don't forget to update your package.json to point to the new locations and add
a prepack
script to compile your TypeScript:
{
// ...
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": ["dist"],
"scripts": {
// ...
"watch": "tsc --watch",
"prepack": "tsc"
},
"devDependencies": {
// ...
"@tsconfig/node18": "^18.2.4",
"typescript": "^5.7.2"
}
}
Upgrade to V5 dependencies
At time of writing, V5 is still in beta, so you should use the @beta
tag to
install devDependencies. Go through each PostGraphile V4 related package
referenced in package.json and install the @beta
version of it, for example:
{
"devDependencies": {
"graphile-build": "^4.12.0-alpha.0",
"graphile-build-pg": "^4.12.0-alpha.0",
"postgraphile": "^4.12.0-alpha.0",
"postgraphile-plugin-connection-filter": "^2.2.0"
}
}
yarn add --dev \
graphile-build@beta \
graphile-build-pg@beta \
postgraphile@beta \
postgraphile-plugin-connection-filter@beta
{
"devDependencies": {
"graphile-build": "^5.0.0-beta.27",
"graphile-build-pg": "^5.0.0-beta.31",
"postgraphile": "^5.0.0-beta.32",
"postgraphile-plugin-connection-filter": "^3.0.0-beta.5"
}
}
postgraphile-core
is no moreHuh... that rhymed. But yeah, just use postgraphile
instead.
It's really annoying when you end up with version conflicts, so in V5 we've
made it easy for you to consistently install one version of the package and
depend on parts within that; instead of import { ... } from "graphile-build"
you would now do import { ... } from "postgraphile/graphile-build"
- that way
you don't need to list graphile-build
as a dependency. Your package.json can
now be just:
{
"devDependencies": {
"postgraphile": "^5.0.0-beta.32",
"postgraphile-plugin-connection-filter": "^3.0.0-beta.5"
}
}
You should do this for graphql
as well: import { ... } from "postgraphile/graphql"
.
Perform basic code transforms
TODO: write some codemods to help with this.
See Plugins below for full details, but essentially a V4 plugin like:
import { Plugin } from "graphile-build";
export const MyPlugin: Plugin = (builder) => {
builder.hook("inflection", (inflection, build) => {
return build.extend(inflection, {
myInflector(stuff) {
return stuff + "Stuff";
},
});
});
builder.hook("build", (build) => {
return build.extend(
build,
{ myStuff: () => ["my", "stuff"] },
"Adding myStuff to build",
);
});
builder.hook("init", (_, build) => {
doSomethingWith(build.myStuff());
return _;
});
builder.hook("GraphQLObjectType:fields", (fields, build, context) => {
return build.extend(
fields,
{ myField: { type: build.graphql.GraphQLString } },
"Adding fields from MyPlugin",
);
});
};
in V5 would become a declarative structured object rather than a function containing a lot of function calls:
import type {} from "postgraphile";
export const MyPlugin: GraphileConfig.Plugin = {
name: "MyPlugin",
inflection: {
add: {
myInflector(stuff) {
return stuff + "Stuff";
},
},
},
schema: {
hooks: {
build(build) {
return build.extend(
build,
{ myStuff: () => ["my", "stuff"] },
"Adding myStuff to build",
);
},
init(_, build) {
doSomethingWith(build.myStuff());
return _;
},
GraphQLObjectType_fields(fields, build, context) {
return build.extend(
fields,
{ myField: { type: build.graphql.GraphQLString } },
"Adding fields from MyPlugin",
);
},
},
},
};
Turn on strict typing
Importantly you need at least the following:
{
"compilerOptions": {
// ...
"strict": true,
"noImplicitAny": true
}
}
Fix the TypeScript errors
Run yarn tsc --watch
or similar in a terminal, and start working through the
errors top to bottom. For each error, find the relevant part of this guide
and follow the advice. If it's not clear, ask on
Discord and then submit an update to this page
with
details -
thanks for contributing to making everyone's migration to V5 easier!
Make your plugin exportable
This is optional, you only need it if you want people to be able to use Graphile Export to export a schema using this plugin as executable code, for example to use in serverless situations where bootup time is at a premium.
TODO: document this! For now, see: Exporting your schema and https://star.graphile.org/graphile-export/
TypeScript
It is very strongly recommended that you write plugins in TypeScript. There are two main reasons for this:
- A lot of work has gone into making the plugin and configuration system strongly typed so that you gain auto-complete and documentation on every option, this should make your plugin authoring experience much easier than in V4.
- Since the plugin and configuration system is strongly typed, if you do not extend these types when you're writing your plugin, users that use TypeScript will not be able to supply your configuration options easily.
Since your plugin will likely add attributes to various shared object types, we use declaration merging heavily. Declaration merging allows you to add additional attributes to existing TypeScript interfaces. You should familiarize yourself with declaration merging in the TypeScript documentation if you are not already familiar.
To avoid problems that come from having multiple versions of the same module
thanks to the many and varied package managers (and versions thereof) all
having their own ideas about which modules should be installed where, we merge
into globally scoped namespaces. The main roots for these namespaces that
you'll work with are GraphileConfig
and GraphileBuild
.
Many of the types need to be converted, here's a few:
import("graphile-build").Build
->GraphileBuild.Build
import("graphile-build").Inflection
->GraphileBuild.Inflection
import("graphile-build").Plugin
->GraphileConfig.Plugin
(theinflection
,gather
andschema
scopes therein)import("postgraphile").PostGraphilePlugin
->GraphileConfig.Plugin
(thegrafast
andgrafserv
scopes therein)import("postgraphile").PostGraphileOptions
->GraphileConfig.Preset
(split across the various scopes therein)
General migration changes
Your test suite likely uses standard APIs, so will need to follow the regular V4 migration guide.
No look-ahead
Graphile Build no longer has a look-ahead engine, instead it uses Grafast plans. (You should familiarize yourself with Grafast's documentation.)
That means all of the APIs that related to "data generators" and the
QueryBuilder
and similar no longer exist:
- 🚮
QueryBuilder
- 🚮
getDataFromParsedResolveInfoFragment
- 🚮
addDataGenerator
- 🚮
addArgDataGenerator
- 🚮
queryFromResolveData
- 🚮
selectGraphQLResultFromTable
Similarly you should no longer use resolvers since Grafast plan resolvers replace both of these needs. (You can use traditional resolvers with Grafast, but they lose many of the benefits of plan resolvers. Further, a Grafast plan that does not use any traditional resolvers is considered "pure", so your plugin should aim to not "taint" your users' schemas.)
The good news is that Grafast plan resolvers are typically much (much) shorter and easier to read, write and understand compared to the chaotic mess that was V4's look-ahead system. We'll look at this a bit more in Plans below.
Type registration
In V4 you could define types in an ad-hoc manner as and when you needed them
using the newWithHooks()
function, but this caused havoc at runtime because
it meant that sometimes a type didn't already exist when you needed it — they
were very dependent on ordering. This was particularly obvious when using
makeExtendSchemaPlugin
and trying to use auto-generated types that may or may
not exist yet. Worse still, plugins that built types only if a type with that
name didn't already exist could get tricked into using an incompatible type!
In V5, all types must be registered by name during the init
hook. The types
still are not created until they are needed (and may not be created at all),
but their names and spec generation functions must be registered ahead of time.
This means that when building fields and arguments you can always reference a
type by its name (using build.getTypeByName('TypeNameHere')
).
Instead of build.newWithHooks
you should use the registration methods (note
that instead of passing the constructor as you did in build.newWithHooks
, you
choose the correctly named function):
build.registerObjectType(typeName, scope, specCallback, origin)
build.registerInterfaceType(typeName, scope, specCallback, origin)
build.registerUnionType(typeName, scope, specCallback, origin)
build.registerScalarType(typeName, scope, specCallback, origin)
build.registerEnumType(typeName, scope, specCallback, origin)
build.registerInputObjectType(typeName, scope, specCallback, origin)
They each take 4 arguments:
typeName
is the (unique) name of the type (this would typically come from an inflector)scope
is an object with anyGraphileBuild.Scope
data relevant to this hook, useful for other plugins to register hooks against the typespecCallback
is a callback function that takes no arguments and returns the spec object. Spec objects are similar to the ones that would be used with GraphQL.js constructors, except they have an extra couple of optional convenience properties specific to Grafast.origin
is a string describing where this type came from / why it exists - it's particularly handy when two types try and register the same name
Example
const MyPlugin: GraphileConfig.Plugin = {
name: "MyPlugin",
version: "0.0.0",
schema: {
hooks: {
init(_, build) {
const typeName = inflection.myInflector("MyInflectorInput");
build.registerObjectType(
typeName,
{
/* add scope data here */
},
() => ({
// Here's the spec for the type
description: "...",
fields: {
//...
},
// If this type requires a particular step class, optionally
// specify it here:
//
// assertStep: ObjectStep,
//
// Or if you prefer, you can make `assertStep` a callback that
// throws an error if the step passed is incompatible:
//
// assertStep($step: ExecutableStep): asserts $step is ObjectStep {
// if (!($step instanceof ObjectStep)) {
// throw new Error(`Expected ObjectStep, instead received '${$step}'`);
// }
// },
}),
`Here you'd put a helpful phrase detailing why this type is being registered; useful when two types try and register with the same name`,
);
return _;
},
},
},
};
Introspection
In V5 we've split the schema build process into two parts:
gather
is where data is gathered from external sources (databases, APIs, the file system, etc) and converted into an "input" to feed into the schema phase. Critically,gather
is asynchronous.schema
is where the GraphQL schema is produced from the gathered "input". Critically,schema
is synchronous.
Introspection data is only available in the gather
phase, from there it's
converted into abstractions (resources, codecs, relations and behaviors) which
are used during the schema
phase. Further the introspection system has been
replaced by a standalone module pg-introspection
which is strongly typed - it
actually embeds parts of the TypeScript documentation so that when you hover
over its various properties in your editor it will tell you how Postgres
describes those fields!
As such pgIntrospectionResultsByKind
is no more, and anything that you had
that relied on introspection results will need to be mapped to using the
abstractions. Typically this results in your code being a bit simpler, but there
are somethings you should pay particular attention to:
- In V4 you'd often look at the foreign key constraints on a table and go "forwards" and "backwards" from them. In V5 the abstraction for these is a relation, which only represents one direction. A "backwards" relation is one that has the "isReferencee" property set. The backward and forward relations can have different smart tags.
- Whereas in V4 you'd think in terms of "classes" (tables, views, etc) and
"procs" (functions), in V5 these are all abstracted as "resources" (functions
are resources that accept
parameters
, table-likes are resources that don't); and importantly every resource has acodec
that describes what it returns. Sometimes you should focus oncodec
(e.g. when it doesn't matter if the row has come from a table or function) whereas others you should focus on the resource (when you need to actually get the row). - Changes to behaviors (@omit/etc) should be done during the
gather
phase if they require fetching additional data (e.g. from files, databases, APIs, etc), or using the behavior system otherwise.
Presets
Presets are a collection of plugins, configuration options, and other presets that get merged together recursively to build the users ultimate configuration. A preset is an object with the following base properties (all optional):
extends
- a list of presets that this preset extendsplugins
- a list of plugin objects that this preset makes use ofdisablePlugins
- a list of plugin names (strings) that should be disabled (skipped)
In addition to these common properties, Graphile Config presets have additional optional fields to influence various different "scopes". For more details, see configuration.
In V4 we had a plugin helper called makePluginByCombiningPlugins
; in V5
that's better served by using a preset that simply lists the underlying plugins
to be combined.
Critically, a preset must not have a name
property; this property helps to
distinguish between presets and plugins.
Plugins
In PostGraphile V4 there were two types of plugins:
- schema plugins (functions) interacted with Graphile Engine (graphile-build and graphile-build-pg) and were responsible to changes to your GraphQL schema
- server plugins (objects) interacted with PostGraphile and it's server/CLI and were responsible to changes to how the HTTP requests were handled and other "high level" concerns (including such concerns as which additional schema plugins to load!)
In V5 there is only one type of plugin, a Graphile Config plugin. Graphile Config plugins are objects with the following properties:
name
(required) - a unique name for this pluginversion
(required) a semver-formatted version stringdescription
- a short description of the plugin for use in documentationexperimental
- set this true if the plugin is experimental (no current effect)provides
- a list of "features" (arbitrary strings) that the plugin provides; the plugin name is automatically included in this listafter
- a list of "features" that need to be established before this plugin runsbefore
- a list of "features" that must not be processed until after this plugin runs
In addition to these common properties, Graphile Config plugins have additional optional fields to influence various different "scopes".
V4 "schema" plugins are now primarily concerned with these scopes:
inflection
- naming thingsgather
- gathering the data necessary to build the schema, and outputting a registryschema
- assigning behaviors to entries in the registry and then building a GraphQL schema based on these
Server plugins are likely more concerned with these scopes:
grafserv
- handing HTTP requesstsgrafast
- handling the GraphQL request (e.g. manipulating the context, etc)
Currently the postgraphile
CLI does not accept many options - it is intended
that users will provide options via the graphile.config.ts
(or similar)
file - so there is no plugin interface for adding CLI flags.
plugin.inflection
In V4, "inflection" was one of the hooks that were called whilst building a
schema. In V5, inflection has been promoted to its own phase, primarily because
"naming things" is a global concern that applies to both the gather
and
schema
phases (see below). Inflectors are also now defined in a declarative
(i.e. object properties) way, rather than an imperative (i.e. function calls)
way - this allows the system to inspect plugins without executing them.
.add
Used to add inflectors; when doing so you should also add their type
definitions. Note that the type definitions only include the arguments that you
call the inflector with, the inflector implementation has an extra initial
argument (options
) that the system automatically passes.
Defining connection and list fields should now use the this.listField(...)
and this.connectionField(...)
inflectors respectively as part of their
implementation, this will ensure that the fields are consistently named across
the schema.
The inflectors available have changed a little, use the TypeScript auto-completion to see what inflectors are available.
Example
// Declare the type
declare global {
namespace GraphileBuild {
interface Inflection {
/** Field name for a Connection field returning all rows from the resource. */
allRowsConnection(this: Inflection, resource: PgResource): string;
}
}
}
// Implement the inflector
export const PgAllRowsPlugin: GraphileConfig.Plugin = {
name: "PgAllRowsPlugin",
version: "0.0.0",
inflection: {
add: {
allRowsConnection(
options, // Additional argument, automatically passed by the system
resource, // This is the argument you defined in the types above
) {
return this.connectionField(
this.camelCase(
`all-${this.pluralize(this._singularizedResourceName(resource))}`,
),
);
},
// ...
},
},
// ...
};
.replace
Should you wish to replace an inflector, you may use this hook instead. It
works similarly to add
above, with the following two differences:
- The type declaration is not required (since it already exists, right?)
- an additional first argument is prepended onto the arguments list:
prev
; this is the previous implementation of the inflector, for your inflector to call should it need to.
.ignoreReplaceIfNotExists
You can "replace" an inflector that doesn't exist, but a) you'll get a warning,
and b) the prev
function will be null or undefined.
Alternatively, include the name of the inflector in ignoreReplaceIfNotExists
(array of strings), and if the inflector didn't previously exist then it will
continue to not exist (and we won't warn you about it). This also means that
the prev
argument is guaranteed to exist at runtime.
Example
export const PgAllThePeoplePlugin: GraphileConfig.Plugin = {
name: "PgAllThePeoplePlugin",
version: "0.0.0",
inflection: {
replace: {
allRowsConnection(prev, options, resource) {
return resource.name === "people" ? `allThePeople` : prev!(resource);
},
ignoreReplaceIfNotExists: ["allRowsConnection"],
},
},
};
plugin.gather
If your plugin did anything asynchronous (extremely unlikely) then that work
would now be done during the gather
phase. If this is the case, please reach
out to Benjie for additional documentation!
plugin.schema
Configures the behavior system and implements the schema hooks
.globalBehavior and .entityBehavior
The '@omit' and '@simpleCollections' smart tags have been replaced with the behavior system in V5. Though the V4 preset adds compatibility with the V4 @omit system (by converting the @omit tags to behaviors), your plugins should not use the data from @omit - they should use the behavior data exclusively.
For more information on behavior, see Behavior.
.hooks
If you were writing a schema plugin, this is where the bulk of your replacement will go.
This is where your schema hooks get registered now. A simple first change is
that we've moved from a procedural style to a declarative style. Further, we've
replaced all the :
in the hook names with _
to avoid needing quote marks
and to make them easier to copy/paste (since it's more likely that word
selection in an editor will select the whole string). The three arguments are
still essentially the same as they were.
For example, a V4 plugin that looks like:
const ExamplePlugin: Plugin = (builder) => {
builder.hook("GraphQLObjectType:fields", (fields, build, context) => {
// ...
return fields;
});
};
would look like this in V5:
const ExamplePlugin: GraphileConfig.Plugin = {
name: "ExamplePlugin",
version: "0.0.0",
schema: {
hooks: {
GraphQLObjectType_fields(fields, build, context) {
//...
return fields;
},
},
},
};
Extending build
If your plugin adds capabilities to GraphileBuild.Build
then it must register
them with TypeScript via declaration merging. For example to add a flibble
property to GraphileBuild.Build
, you might do:
// Ensure that the types are imported for TypeScript
import "graphile-build";
import "graphile-config";
// Extend the global GraphileBuild.Build type to add our 'flibble' attribute:
declare global {
namespace GraphileBuild {
interface Build {
flibble: bool;
}
}
}
// And here's the plugin that actually adds the attribute at runtime:
export const FlibblePlugin: GraphileConfig.Plugin = {
name: "FlibblePlugin",
version: "0.0.0",
schema: {
hooks: {
build(build) {
build.flibble = true;
return build;
},
},
},
};
Extending scopes
Similarly all of the scopes and contexts are typed within each hook by
camelcasing the hook name and prepending Scope
or Context
. For example, the
scope in the GraphQLObjectType_fields_field
hook is now
ScopeObjectFieldsField
. If you need to add additional entries to any of these
you should do so via declaration merging, and you should ensure that the
property is optional:
declare global {
namespace GraphileBuild {
interface ScopeObjectFieldsField {
isRootNodeField?: boolean;
}
}
}
Adding configuration options
If your plugin requires the user to pass configuration options, most likely
they should be in the preset.schema
scope which should be retrieved via
build.options
. When doing so, we also need to add to the declaration-merged
types so that TypeScript understands the presence of the new option:
declare global {
namespace GraphileBuild {
interface SchemaOptions {
/**
* The default option to use for the 'includeArchived' argument. Defaults to
* 'INHERIT' where feasible and 'NO' otherwise.
*/
pgArchivedDefault?: "INHERIT" | "NO" | "YES" | "EXCLUSIVELY";
}
}
}
Note also that tools like graphile config options
will look at these
TypeScript definitions and use them to provide documentation to the user - as
such you should be sure to add a /** ... */
comment describing the feature,
as we have above.
Plans
As we read earlier, there's no look-ahead system in PostGraphile V5; instead we use Grafast's planning system.
Where you used to use addArgDataGenerator
you should now give your argument
an applyPlan
and set autoApplyAfterParentPlan: true
so that the plan is
automatically applied (without the parent field having to call
fieldArgs.apply($target, 'argName')
).
Typically where you'd use a QueryBuilder
(queryBuilder
) in V4, you'll be
dealing with a PgSelectStep
($pgSelect
) in V5. Note that these are
significantly different things, but they do have some parallels:
QueryBuilder.getTableAlias()
->$pgSelect.alias
QueryBuilder.where(fragment)
->$pgSelect.where(fragment)
QueryBuilder.orderBy(...)
->$pgSelect.orderBy(...)
(arguments differ, see TypeScript for details)
Note that it's common to be dealing with a PgSelectSingleStep
($pgSelectSingle
) when you're looking at a single record rather than the
collection. In this case should you need to get back to the collection (e.g. to
get the alias) you can do $pgSelectSingle.getClassStep()
.
Should you have code that uses queryBuilder.parentQueryBuilder
there's no
direct parallel. Instead, you should use the parent step and get what you need
from there, and then embed that value into your query using a placeholder:
// V4
const parentAlias = queryBuilder.parentQueryBuilder.getTableAlias();
queryBuilder.where(sql.fragment`${parentAlias}.archived_at is not true`);
// V5
const $archivedAt = $parent.get("archived_at");
const archivedAtFrag = $pgSelect.placeholder($archivedAt);
$pgSelect.where(sql`${archivedAtFrag} is not true`);
applyPlan
on an argument accepts 4 arguments, the first is the parent step
($parent
), which is the step that the field itself was called on. The second
is the target step ($pgSelect
), which is typically the result of the fields'
plan resolver. Note that input objects' applyPlan
s only have the latter 3
arguments - they do not have access to the parent step unless their parent
input object or argument applyPlan
explicitly pass them down.
In general, unlike in V4, you should not assume too much about how the SQL will
be generated. It's better to use simple methods like
$record.get('column_name')
to retrieve attributes and then embed these values
using $pgSelect.placeholder(...)
than it is to make assumptions about the
shape of the request and try and be clever and use aliases/etc. Normally
@dataplan/pg
will be able to figure out the best way to address your needs,
and will inline things as necessary/optimal.
Examples
Here are some conversions that have taken place on some of the community plugins: