Skip to content

Commit

Permalink
feat(postgraphql): add schema watch functionality to CLI and middlewa…
Browse files Browse the repository at this point in the history
…re (#166)

* add introspection watch query

* feat(postgraphql): add Postgres watch to middleware

* doc(library): update library docs with new option

* Update library.md

* Update library.md

* Update watch-fixtures.sql
  • Loading branch information
calebmer authored Oct 18, 2016
1 parent a8a3e91 commit 396bca4
Show file tree
Hide file tree
Showing 13 changed files with 393 additions and 57 deletions.
1 change: 1 addition & 0 deletions docs/library.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ Arguments include:
- `pgDefaultRole`: The default Postgres role to use. If no role was provided in a provided JWT token, this role will be used.
- `jwtSecret`: The secret for your JSON web tokens. This will be used to verify tokens in the `Authorization` header, and signing JWT tokens you return in procedures.
- `jwtPgTypeIdentifier`: The Postgres type identifier for the compound type which will be signed as a JWT token if ever found as the return type of a procedure. Can be of the form: `my_schema.my_type`. You may use quotes as needed: `"my-special-schema".my_type`.
- `watchPg`: When true, PostGraphQL will watch your database schemas and re-create the GraphQL API whenever your schema changes, notifying you as it does. This feature requires an event trigger to be added to the database by a superuser. When enabled PostGraphQL will try to add this trigger, if you did not connect as a superuser you will get a warning and the trigger won’t be added.
- `disableQueryLog`: Turns off GraphQL query logging. By default PostGraphQL will log every GraphQL query it processes along with some other information. Set this to `true` to disable that feature.
- `enableCors`: Enables some generous CORS settings for the GraphQL endpoint. There are some costs associated when enabling this, if at all possible try to put your API behind a reverse proxy.

Expand Down
3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,6 @@
"testPathDirs": [
"<rootDir>/src"
],
"testRegex": "/__tests__/[^.]+-test.(t|j)s$",
"clearMocks": true
"testRegex": "/__tests__/[^.]+-test.(t|j)s$"
}
}
61 changes: 61 additions & 0 deletions resources/watch-fixtures.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
-- Adds the functionality for PostGraphQL to watch the database for schema
-- changes. This script is idempotent, you can run it as many times as you
-- would like.

begin;

-- Drop the `postgraphql_watch` schema and all of its dependant objects
-- including the event trigger function and the event trigger itself. We will
-- recreate those objects in this script.
drop schema if exists postgraphql_watch cascade;

-- Create a schema for the PostGraphQL watch functionality. This schema will
-- hold things like trigger functions that are used to implement schema
-- watching.
create schema postgraphql_watch;

-- This function will notify PostGraphQL of schema changes via a trigger.
create function postgraphql_watch.notify_watchers() returns event_trigger as $$
begin
perform pg_notify(
'postgraphql_watch',
(select array_to_json(array_agg(x)) from (select schema_name as schema, command_tag as command from pg_event_trigger_ddl_commands()) as x)::text
);
end;
$$ language plpgsql;

-- Create an event trigger which will listen for the completion of all DDL
-- events and report that they happened to PostGraphQL. Events are selected by
-- whether or not they modify the static definition of `pg_catalog` that
-- `introspection-query.sql` queries.
create event trigger postgraphql_watch
on ddl_command_end
when tag in (
'ALTER DOMAIN',
'ALTER FOREIGN TABLE',
'ALTER FUNCTION',
'ALTER SCHEMA',
'ALTER TABLE',
'ALTER TYPE',
'ALTER VIEW',
'COMMENT',
'CREATE DOMAIN',
'CREATE FOREIGN TABLE',
'CREATE FUNCTION',
'CREATE SCHEMA',
'CREATE TABLE',
'CREATE TABLE AS',
'CREATE VIEW',
'DROP DOMAIN',
'DROP FOREIGN TABLE',
'DROP FUNCTION',
'DROP SCHEMA',
'DROP TABLE',
'DROP VIEW',
'GRANT',
'REVOKE',
'SELECT INTO'
)
execute procedure postgraphql_watch.notify_watchers();

commit;
2 changes: 1 addition & 1 deletion scripts/dev
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,4 @@ $npm_bin/nodemon \
--ignore __tests__ \
--ignore __mocks__ \
--ext js,ts \
--exec "$npm_bin/ts-node --ignore node_modules --disableWarnings src/postgraphql/cli.ts --schema a,b,c --show-error-stack json $@"
--exec "$npm_bin/ts-node --ignore node_modules --disableWarnings src/postgraphql/cli.ts --schema a,b,c --show-error-stack json --watch $@"
71 changes: 63 additions & 8 deletions src/postgraphql/__tests__/postgraphql-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,19 @@ jest.mock('pg')
jest.mock('pg-connection-string')
jest.mock('../schema/createPostGraphQLSchema')
jest.mock('../http/createPostGraphQLHttpRequestHandler')
jest.mock('../watch/watchPgSchemas')

import { Pool } from 'pg'
import { parse as parsePgConnectionString } from 'pg-connection-string'
import createPostGraphQLSchema from '../schema/createPostGraphQLSchema'
import createPostGraphQLHttpRequestHandler from '../http/createPostGraphQLHttpRequestHandler'
import watchPgSchemas from '../watch/watchPgSchemas'
import postgraphql from '../postgraphql'

createPostGraphQLHttpRequestHandler
.mockImplementation(({ graphqlSchema }) => Promise.resolve(graphqlSchema).then(() => null))
const chalk = require('chalk')

createPostGraphQLHttpRequestHandler.mockImplementation(({ getGqlSchema }) => Promise.resolve(getGqlSchema()).then(() => null))
watchPgSchemas.mockImplementation(() => Promise.resolve())

test('will use the first parameter as the pool if it is an instance of `Pool`', async () => {
const pgPool = new Pool()
Expand Down Expand Up @@ -50,7 +54,7 @@ test('will use a connected client from the pool, the schemas, and options to cre
createPostGraphQLSchema.mockClear()
createPostGraphQLHttpRequestHandler.mockClear()
const pgPool = new Pool()
const schemas = Symbol('schemas')
const schemas = [Symbol('schemas')]
const options = Symbol('options')
const pgClient = { release: jest.fn() }
pgPool.connect.mockReturnValue(Promise.resolve(pgClient))
Expand All @@ -60,13 +64,64 @@ test('will use a connected client from the pool, the schemas, and options to cre
expect(pgClient.release.mock.calls).toEqual([[]])
})

test('will use a created GraphQL schema to create the Http request handler and pass down options', async () => {
test('will use a created GraphQL schema to create the HTTP request handler and pass down options', async () => {
createPostGraphQLHttpRequestHandler.mockClear()
const pgPool = new Pool()
const graphqlSchema = Promise.resolve(Symbol('graphqlSchema'))
const gqlSchema = Symbol('graphqlSchema')
const options = { a: 1, b: 2, c: 3 }
createPostGraphQLSchema.mockReturnValueOnce(graphqlSchema)
createPostGraphQLSchema.mockReturnValueOnce(Promise.resolve(gqlSchema))
await postgraphql(pgPool, null, options)
expect(createPostGraphQLHttpRequestHandler.mock.calls)
.toEqual([[{ pgPool, graphqlSchema, a: 1, b: 2, c: 3 }]])
expect(createPostGraphQLHttpRequestHandler.mock.calls.length).toBe(1)
expect(createPostGraphQLHttpRequestHandler.mock.calls[0].length).toBe(1)
expect(Object.keys(createPostGraphQLHttpRequestHandler.mock.calls[0][0])).toEqual(['a', 'b', 'c', 'getGqlSchema', 'pgPool'])
expect(createPostGraphQLHttpRequestHandler.mock.calls[0][0].pgPool).toBe(pgPool)
expect(createPostGraphQLHttpRequestHandler.mock.calls[0][0].a).toBe(options.a)
expect(createPostGraphQLHttpRequestHandler.mock.calls[0][0].b).toBe(options.b)
expect(createPostGraphQLHttpRequestHandler.mock.calls[0][0].c).toBe(options.c)
expect(await createPostGraphQLHttpRequestHandler.mock.calls[0][0].getGqlSchema()).toBe(gqlSchema)
})

test('will watch Postgres schemas when `watchPg` is true', async () => {
const pgPool = new Pool()
const pgSchemas = [Symbol('a'), Symbol('b'), Symbol('c')]
await postgraphql(pgPool, pgSchemas, { watchPg: false })
await postgraphql(pgPool, pgSchemas, { watchPg: true })
expect(watchPgSchemas.mock.calls.length).toBe(1)
expect(watchPgSchemas.mock.calls[0].length).toBe(1)
expect(Object.keys(watchPgSchemas.mock.calls[0][0])).toEqual(['pgPool', 'pgSchemas', 'onChange'])
expect(watchPgSchemas.mock.calls[0][0].pgPool).toBe(pgPool)
expect(watchPgSchemas.mock.calls[0][0].pgSchemas).toBe(pgSchemas)
expect(typeof watchPgSchemas.mock.calls[0][0].onChange).toBe('function')
})

test('will create a new PostGraphQL schema on when `watchPgSchemas` emits a change', async () => {
watchPgSchemas.mockClear()
createPostGraphQLHttpRequestHandler.mockClear()
const gqlSchemas = [Symbol('a'), Symbol('b'), Symbol('c')]
let gqlSchemaI = 0
createPostGraphQLSchema.mockClear()
createPostGraphQLSchema.mockImplementation(() => Promise.resolve(gqlSchemas[gqlSchemaI++]))
const pgPool = new Pool()
const pgClient = { release: jest.fn() }
pgPool.connect.mockReturnValue(Promise.resolve(pgClient))
const mockLog = jest.fn()
const origLog = console.log
console.log = mockLog
await postgraphql(pgPool, [], { watchPg: true })
const { onChange } = watchPgSchemas.mock.calls[0][0]
const { getGqlSchema } = createPostGraphQLHttpRequestHandler.mock.calls[0][0]
expect(pgPool.connect.mock.calls).toEqual([[]])
expect(pgClient.release.mock.calls).toEqual([[]])
expect(await getGqlSchema()).toBe(gqlSchemas[0])
onChange({ commands: ['a', 'b', 'c'] })
expect(await getGqlSchema()).toBe(gqlSchemas[1])
onChange({ commands: ['d', 'e'] })
expect(await getGqlSchema()).toBe(gqlSchemas[2])
expect(pgPool.connect.mock.calls).toEqual([[], [], []])
expect(pgClient.release.mock.calls).toEqual([[], [], []])
expect(mockLog.mock.calls).toEqual([
[`Restarting PostGraphQL API after Postgres command(s): ️${chalk.bold.cyan('a')}, ${chalk.bold.cyan('b')}, ${chalk.bold.cyan('c')}`],
[`Restarting PostGraphQL API after Postgres command(s): ️${chalk.bold.cyan('d')}, ${chalk.bold.cyan('e')}`],
])
console.log = origLog
})
5 changes: 4 additions & 1 deletion src/postgraphql/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ program
// .option('-d, --demo', 'run PostGraphQL using the demo database connection')
.option('-c, --connection <string>', 'the Postgres connection. if not provided it will be inferred from your environment')
.option('-s, --schema <string>', 'a Postgres schema to be introspected. Use commas to define multiple schemas', (option: string) => option.split(','))
.option('-w, --watch', 'watches the Postgres schema for changes and reruns introspection if a change was detected')
.option('-n, --host <string>', 'the hostname to be used. Defaults to `localhost`')
.option('-p, --port <number>', 'the port to be used. Defaults to 5000', parseFloat)
.option('-m, --max-pool-size <number>', 'the maximum number of clients to keep in the Postgres pool. defaults to 10', parseFloat)
Expand Down Expand Up @@ -54,6 +55,7 @@ process.on('SIGINT', process.exit)
const {
demo: isDemo = false,
connection: pgConnectionString,
watch: watchPg,
host: hostname = 'localhost',
port = 5000,
maxPoolSize,
Expand Down Expand Up @@ -102,9 +104,10 @@ const server = createServer(postgraphql(pgConfig, schemas, {
jwtSecret,
jwtPgTypeIdentifier,
pgDefaultRole,
watchPg,
showErrorStack,
disableQueryLog: false,
enableCors,
showErrorStack,
}))

// Start our server by listening to a specific port and host name. Also log
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const connect = require('connect')
const express = require('express')
const Koa = require('koa')

const graphqlSchema = new GraphQLSchema({
const gqlSchema = new GraphQLSchema({
query: new GraphQLObjectType({
name: 'Query',
fields: {
Expand Down Expand Up @@ -54,7 +54,7 @@ const pgPool = {
}

const defaultOptions = {
graphqlSchema,
getGqlSchema: () => gqlSchema,
pgPool,
disableQueryLog: true,
}
Expand Down Expand Up @@ -435,26 +435,18 @@ for (const [name, createServerFromHandler] of serverCreators) {
)
})

test('can use a promised GraphQL schema', async () => {
test('cannot use a rejected GraphQL schema', async () => {
const rejectedGraphQLSchema = Promise.reject(new Error('Uh oh!'))
// We don’t want Jest to complain about uncaught promise rejections.
rejectedGraphQLSchema.catch(() => {})
const server1 = createServer({ graphqlSchema: Promise.resolve(graphqlSchema) })
const server2 = createServer({ graphqlSchema: rejectedGraphQLSchema })
await (
request(server1)
.post('/graphql')
.send({ query: '{hello}' })
.expect(200)
.expect({ data: { hello: 'world' } })
)
const server = createServer({ getGqlSchema: () => rejectedGraphQLSchema })
// We want to hide `console.error` warnings because we are intentionally
// generating some here.
const origConsoleError = console.error
console.error = () => {}
try {
await (
request(server2)
request(server)
.post('/graphql')
.send({ query: '{hello}' })
.expect(500)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export interface HttpRequestHandler {
*/
export default function createPostGraphQLHttpRequestHandler (config: {
// The actual GraphQL schema we will use.
graphqlSchema: GraphQLSchema | Promise<GraphQLSchema>,
getGqlSchema: () => Promise<GraphQLSchema>,

// A Postgres client pool we use to connect Postgres clients.
pgPool: Pool,
Expand Down
22 changes: 13 additions & 9 deletions src/postgraphql/http/createPostGraphQLHttpRequestHandler.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ const favicon = new Promise((resolve, reject) => {
* @param {GraphQLSchema} graphqlSchema
*/
export default function createPostGraphQLHttpRequestHandler (options) {
const { graphqlSchema, pgPool } = options
const { getGqlSchema, pgPool } = options

// Gets the route names for our GraphQL endpoint, and our GraphiQL endpoint.
const graphqlRoute = options.graphqlRoute || '/graphql'
Expand Down Expand Up @@ -159,7 +159,7 @@ export default function createPostGraphQLHttpRequestHandler (options) {
// a result. We also keep track of `params`.
let params
let result
let queryDocumentAST
let queryDocumentAst
const queryTimeStart = process.hrtime()
let pgRole

Expand All @@ -169,6 +169,10 @@ export default function createPostGraphQLHttpRequestHandler (options) {
// GraphQL query. All errors thrown in this block will be returned to the
// client as GraphQL errors.
try {
// First thing we need to do is get the GraphQL schema for this request.
// It should never really change unless we are in watch mode.
const gqlSchema = await getGqlSchema()

// Run all of our middleware by converting them into promises and
// chaining them together. Remember that if we have a middleware that
// never calls `next`, we will have a promise that never resolves! Avoid
Expand Down Expand Up @@ -241,7 +245,7 @@ export default function createPostGraphQLHttpRequestHandler (options) {
// Catch an errors while parsing so that we can set the `statusCode` to
// 400. Otherwise we don’t need to parse this way.
try {
queryDocumentAST = parseGraphql(source)
queryDocumentAst = parseGraphql(source)
}
catch (error) {
res.statusCode = 400
Expand All @@ -252,7 +256,7 @@ export default function createPostGraphQLHttpRequestHandler (options) {

// Validate our GraphQL query using given rules.
// TODO: Add a complexity GraphQL rule.
const validationErrors = validateGraphql(await graphqlSchema, queryDocumentAST)
const validationErrors = validateGraphql(gqlSchema, queryDocumentAst)

// If we have some validation errors, don’t execute the query. Instead
// send the errors to the client with a `400` code.
Expand All @@ -266,7 +270,7 @@ export default function createPostGraphQLHttpRequestHandler (options) {

// Lazily log the query. If this debugger isn’t enabled, don’t run it.
if (debugGraphql.enabled)
debugGraphql(printGraphql(queryDocumentAST).replace(/\s+/g, ' ').trim())
debugGraphql(printGraphql(queryDocumentAst).replace(/\s+/g, ' ').trim())

// Connect a new Postgres client and start a transaction.
const pgClient = await pgPool.connect()
Expand All @@ -283,8 +287,8 @@ export default function createPostGraphQLHttpRequestHandler (options) {

try {
result = await executeGraphql(
await graphqlSchema,
queryDocumentAST,
gqlSchema,
queryDocumentAst,
null,
{ [$$pgClient]: pgClient },
params.variables,
Expand Down Expand Up @@ -321,8 +325,8 @@ export default function createPostGraphQLHttpRequestHandler (options) {
debugRequest('GraphQL query request finished.')

// Log the query. If this debugger isn’t enabled, don’t run it.
if (queryDocumentAST && !options.disableQueryLog) {
const prettyQuery = printGraphql(queryDocumentAST).replace(/\s+/g, ' ').trim()
if (queryDocumentAst && !options.disableQueryLog) {
const prettyQuery = printGraphql(queryDocumentAst).replace(/\s+/g, ' ').trim()
const errorCount = (result.errors || []).length
const timeDiff = process.hrtime(queryTimeStart)
const ms = Math.round((timeDiff[0] * 1e9 + timeDiff[1]) * 10e-7 * 100) / 100
Expand Down
Loading

0 comments on commit 396bca4

Please sign in to comment.