Skip to content

Commit 396bca4

Browse files
authored
feat(postgraphql): add schema watch functionality to CLI and middleware (#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
1 parent a8a3e91 commit 396bca4

13 files changed

+393
-57
lines changed

docs/library.md

+1
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ Arguments include:
5050
- `pgDefaultRole`: The default Postgres role to use. If no role was provided in a provided JWT token, this role will be used.
5151
- `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.
5252
- `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`.
53+
- `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.
5354
- `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.
5455
- `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.
5556

package.json

+1-2
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,6 @@
7979
"testPathDirs": [
8080
"<rootDir>/src"
8181
],
82-
"testRegex": "/__tests__/[^.]+-test.(t|j)s$",
83-
"clearMocks": true
82+
"testRegex": "/__tests__/[^.]+-test.(t|j)s$"
8483
}
8584
}

resources/watch-fixtures.sql

+61
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
-- Adds the functionality for PostGraphQL to watch the database for schema
2+
-- changes. This script is idempotent, you can run it as many times as you
3+
-- would like.
4+
5+
begin;
6+
7+
-- Drop the `postgraphql_watch` schema and all of its dependant objects
8+
-- including the event trigger function and the event trigger itself. We will
9+
-- recreate those objects in this script.
10+
drop schema if exists postgraphql_watch cascade;
11+
12+
-- Create a schema for the PostGraphQL watch functionality. This schema will
13+
-- hold things like trigger functions that are used to implement schema
14+
-- watching.
15+
create schema postgraphql_watch;
16+
17+
-- This function will notify PostGraphQL of schema changes via a trigger.
18+
create function postgraphql_watch.notify_watchers() returns event_trigger as $$
19+
begin
20+
perform pg_notify(
21+
'postgraphql_watch',
22+
(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
23+
);
24+
end;
25+
$$ language plpgsql;
26+
27+
-- Create an event trigger which will listen for the completion of all DDL
28+
-- events and report that they happened to PostGraphQL. Events are selected by
29+
-- whether or not they modify the static definition of `pg_catalog` that
30+
-- `introspection-query.sql` queries.
31+
create event trigger postgraphql_watch
32+
on ddl_command_end
33+
when tag in (
34+
'ALTER DOMAIN',
35+
'ALTER FOREIGN TABLE',
36+
'ALTER FUNCTION',
37+
'ALTER SCHEMA',
38+
'ALTER TABLE',
39+
'ALTER TYPE',
40+
'ALTER VIEW',
41+
'COMMENT',
42+
'CREATE DOMAIN',
43+
'CREATE FOREIGN TABLE',
44+
'CREATE FUNCTION',
45+
'CREATE SCHEMA',
46+
'CREATE TABLE',
47+
'CREATE TABLE AS',
48+
'CREATE VIEW',
49+
'DROP DOMAIN',
50+
'DROP FOREIGN TABLE',
51+
'DROP FUNCTION',
52+
'DROP SCHEMA',
53+
'DROP TABLE',
54+
'DROP VIEW',
55+
'GRANT',
56+
'REVOKE',
57+
'SELECT INTO'
58+
)
59+
execute procedure postgraphql_watch.notify_watchers();
60+
61+
commit;

scripts/dev

+1-1
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,4 @@ $npm_bin/nodemon \
99
--ignore __tests__ \
1010
--ignore __mocks__ \
1111
--ext js,ts \
12-
--exec "$npm_bin/ts-node --ignore node_modules --disableWarnings src/postgraphql/cli.ts --schema a,b,c --show-error-stack json $@"
12+
--exec "$npm_bin/ts-node --ignore node_modules --disableWarnings src/postgraphql/cli.ts --schema a,b,c --show-error-stack json --watch $@"

src/postgraphql/__tests__/postgraphql-test.js

+63-8
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,19 @@ jest.mock('pg')
22
jest.mock('pg-connection-string')
33
jest.mock('../schema/createPostGraphQLSchema')
44
jest.mock('../http/createPostGraphQLHttpRequestHandler')
5+
jest.mock('../watch/watchPgSchemas')
56

67
import { Pool } from 'pg'
78
import { parse as parsePgConnectionString } from 'pg-connection-string'
89
import createPostGraphQLSchema from '../schema/createPostGraphQLSchema'
910
import createPostGraphQLHttpRequestHandler from '../http/createPostGraphQLHttpRequestHandler'
11+
import watchPgSchemas from '../watch/watchPgSchemas'
1012
import postgraphql from '../postgraphql'
1113

12-
createPostGraphQLHttpRequestHandler
13-
.mockImplementation(({ graphqlSchema }) => Promise.resolve(graphqlSchema).then(() => null))
14+
const chalk = require('chalk')
15+
16+
createPostGraphQLHttpRequestHandler.mockImplementation(({ getGqlSchema }) => Promise.resolve(getGqlSchema()).then(() => null))
17+
watchPgSchemas.mockImplementation(() => Promise.resolve())
1418

1519
test('will use the first parameter as the pool if it is an instance of `Pool`', async () => {
1620
const pgPool = new Pool()
@@ -50,7 +54,7 @@ test('will use a connected client from the pool, the schemas, and options to cre
5054
createPostGraphQLSchema.mockClear()
5155
createPostGraphQLHttpRequestHandler.mockClear()
5256
const pgPool = new Pool()
53-
const schemas = Symbol('schemas')
57+
const schemas = [Symbol('schemas')]
5458
const options = Symbol('options')
5559
const pgClient = { release: jest.fn() }
5660
pgPool.connect.mockReturnValue(Promise.resolve(pgClient))
@@ -60,13 +64,64 @@ test('will use a connected client from the pool, the schemas, and options to cre
6064
expect(pgClient.release.mock.calls).toEqual([[]])
6165
})
6266

63-
test('will use a created GraphQL schema to create the Http request handler and pass down options', async () => {
67+
test('will use a created GraphQL schema to create the HTTP request handler and pass down options', async () => {
6468
createPostGraphQLHttpRequestHandler.mockClear()
6569
const pgPool = new Pool()
66-
const graphqlSchema = Promise.resolve(Symbol('graphqlSchema'))
70+
const gqlSchema = Symbol('graphqlSchema')
6771
const options = { a: 1, b: 2, c: 3 }
68-
createPostGraphQLSchema.mockReturnValueOnce(graphqlSchema)
72+
createPostGraphQLSchema.mockReturnValueOnce(Promise.resolve(gqlSchema))
6973
await postgraphql(pgPool, null, options)
70-
expect(createPostGraphQLHttpRequestHandler.mock.calls)
71-
.toEqual([[{ pgPool, graphqlSchema, a: 1, b: 2, c: 3 }]])
74+
expect(createPostGraphQLHttpRequestHandler.mock.calls.length).toBe(1)
75+
expect(createPostGraphQLHttpRequestHandler.mock.calls[0].length).toBe(1)
76+
expect(Object.keys(createPostGraphQLHttpRequestHandler.mock.calls[0][0])).toEqual(['a', 'b', 'c', 'getGqlSchema', 'pgPool'])
77+
expect(createPostGraphQLHttpRequestHandler.mock.calls[0][0].pgPool).toBe(pgPool)
78+
expect(createPostGraphQLHttpRequestHandler.mock.calls[0][0].a).toBe(options.a)
79+
expect(createPostGraphQLHttpRequestHandler.mock.calls[0][0].b).toBe(options.b)
80+
expect(createPostGraphQLHttpRequestHandler.mock.calls[0][0].c).toBe(options.c)
81+
expect(await createPostGraphQLHttpRequestHandler.mock.calls[0][0].getGqlSchema()).toBe(gqlSchema)
82+
})
83+
84+
test('will watch Postgres schemas when `watchPg` is true', async () => {
85+
const pgPool = new Pool()
86+
const pgSchemas = [Symbol('a'), Symbol('b'), Symbol('c')]
87+
await postgraphql(pgPool, pgSchemas, { watchPg: false })
88+
await postgraphql(pgPool, pgSchemas, { watchPg: true })
89+
expect(watchPgSchemas.mock.calls.length).toBe(1)
90+
expect(watchPgSchemas.mock.calls[0].length).toBe(1)
91+
expect(Object.keys(watchPgSchemas.mock.calls[0][0])).toEqual(['pgPool', 'pgSchemas', 'onChange'])
92+
expect(watchPgSchemas.mock.calls[0][0].pgPool).toBe(pgPool)
93+
expect(watchPgSchemas.mock.calls[0][0].pgSchemas).toBe(pgSchemas)
94+
expect(typeof watchPgSchemas.mock.calls[0][0].onChange).toBe('function')
95+
})
96+
97+
test('will create a new PostGraphQL schema on when `watchPgSchemas` emits a change', async () => {
98+
watchPgSchemas.mockClear()
99+
createPostGraphQLHttpRequestHandler.mockClear()
100+
const gqlSchemas = [Symbol('a'), Symbol('b'), Symbol('c')]
101+
let gqlSchemaI = 0
102+
createPostGraphQLSchema.mockClear()
103+
createPostGraphQLSchema.mockImplementation(() => Promise.resolve(gqlSchemas[gqlSchemaI++]))
104+
const pgPool = new Pool()
105+
const pgClient = { release: jest.fn() }
106+
pgPool.connect.mockReturnValue(Promise.resolve(pgClient))
107+
const mockLog = jest.fn()
108+
const origLog = console.log
109+
console.log = mockLog
110+
await postgraphql(pgPool, [], { watchPg: true })
111+
const { onChange } = watchPgSchemas.mock.calls[0][0]
112+
const { getGqlSchema } = createPostGraphQLHttpRequestHandler.mock.calls[0][0]
113+
expect(pgPool.connect.mock.calls).toEqual([[]])
114+
expect(pgClient.release.mock.calls).toEqual([[]])
115+
expect(await getGqlSchema()).toBe(gqlSchemas[0])
116+
onChange({ commands: ['a', 'b', 'c'] })
117+
expect(await getGqlSchema()).toBe(gqlSchemas[1])
118+
onChange({ commands: ['d', 'e'] })
119+
expect(await getGqlSchema()).toBe(gqlSchemas[2])
120+
expect(pgPool.connect.mock.calls).toEqual([[], [], []])
121+
expect(pgClient.release.mock.calls).toEqual([[], [], []])
122+
expect(mockLog.mock.calls).toEqual([
123+
[`Restarting PostGraphQL API after Postgres command(s): ️${chalk.bold.cyan('a')}, ${chalk.bold.cyan('b')}, ${chalk.bold.cyan('c')}`],
124+
[`Restarting PostGraphQL API after Postgres command(s): ️${chalk.bold.cyan('d')}, ${chalk.bold.cyan('e')}`],
125+
])
126+
console.log = origLog
72127
})

src/postgraphql/cli.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ program
2323
// .option('-d, --demo', 'run PostGraphQL using the demo database connection')
2424
.option('-c, --connection <string>', 'the Postgres connection. if not provided it will be inferred from your environment')
2525
.option('-s, --schema <string>', 'a Postgres schema to be introspected. Use commas to define multiple schemas', (option: string) => option.split(','))
26+
.option('-w, --watch', 'watches the Postgres schema for changes and reruns introspection if a change was detected')
2627
.option('-n, --host <string>', 'the hostname to be used. Defaults to `localhost`')
2728
.option('-p, --port <number>', 'the port to be used. Defaults to 5000', parseFloat)
2829
.option('-m, --max-pool-size <number>', 'the maximum number of clients to keep in the Postgres pool. defaults to 10', parseFloat)
@@ -54,6 +55,7 @@ process.on('SIGINT', process.exit)
5455
const {
5556
demo: isDemo = false,
5657
connection: pgConnectionString,
58+
watch: watchPg,
5759
host: hostname = 'localhost',
5860
port = 5000,
5961
maxPoolSize,
@@ -102,9 +104,10 @@ const server = createServer(postgraphql(pgConfig, schemas, {
102104
jwtSecret,
103105
jwtPgTypeIdentifier,
104106
pgDefaultRole,
107+
watchPg,
108+
showErrorStack,
105109
disableQueryLog: false,
106110
enableCors,
107-
showErrorStack,
108111
}))
109112

110113
// Start our server by listening to a specific port and host name. Also log

src/postgraphql/http/__tests__/createPostGraphQLHttpRequestHandler-test.js

+5-13
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ const connect = require('connect')
1111
const express = require('express')
1212
const Koa = require('koa')
1313

14-
const graphqlSchema = new GraphQLSchema({
14+
const gqlSchema = new GraphQLSchema({
1515
query: new GraphQLObjectType({
1616
name: 'Query',
1717
fields: {
@@ -54,7 +54,7 @@ const pgPool = {
5454
}
5555

5656
const defaultOptions = {
57-
graphqlSchema,
57+
getGqlSchema: () => gqlSchema,
5858
pgPool,
5959
disableQueryLog: true,
6060
}
@@ -435,26 +435,18 @@ for (const [name, createServerFromHandler] of serverCreators) {
435435
)
436436
})
437437

438-
test('can use a promised GraphQL schema', async () => {
438+
test('cannot use a rejected GraphQL schema', async () => {
439439
const rejectedGraphQLSchema = Promise.reject(new Error('Uh oh!'))
440440
// We don’t want Jest to complain about uncaught promise rejections.
441441
rejectedGraphQLSchema.catch(() => {})
442-
const server1 = createServer({ graphqlSchema: Promise.resolve(graphqlSchema) })
443-
const server2 = createServer({ graphqlSchema: rejectedGraphQLSchema })
444-
await (
445-
request(server1)
446-
.post('/graphql')
447-
.send({ query: '{hello}' })
448-
.expect(200)
449-
.expect({ data: { hello: 'world' } })
450-
)
442+
const server = createServer({ getGqlSchema: () => rejectedGraphQLSchema })
451443
// We want to hide `console.error` warnings because we are intentionally
452444
// generating some here.
453445
const origConsoleError = console.error
454446
console.error = () => {}
455447
try {
456448
await (
457-
request(server2)
449+
request(server)
458450
.post('/graphql')
459451
.send({ query: '{hello}' })
460452
.expect(500)

src/postgraphql/http/createPostGraphQLHttpRequestHandler.d.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ export interface HttpRequestHandler {
2020
*/
2121
export default function createPostGraphQLHttpRequestHandler (config: {
2222
// The actual GraphQL schema we will use.
23-
graphqlSchema: GraphQLSchema | Promise<GraphQLSchema>,
23+
getGqlSchema: () => Promise<GraphQLSchema>,
2424

2525
// A Postgres client pool we use to connect Postgres clients.
2626
pgPool: Pool,

src/postgraphql/http/createPostGraphQLHttpRequestHandler.js

+13-9
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ const favicon = new Promise((resolve, reject) => {
3939
* @param {GraphQLSchema} graphqlSchema
4040
*/
4141
export default function createPostGraphQLHttpRequestHandler (options) {
42-
const { graphqlSchema, pgPool } = options
42+
const { getGqlSchema, pgPool } = options
4343

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

@@ -169,6 +169,10 @@ export default function createPostGraphQLHttpRequestHandler (options) {
169169
// GraphQL query. All errors thrown in this block will be returned to the
170170
// client as GraphQL errors.
171171
try {
172+
// First thing we need to do is get the GraphQL schema for this request.
173+
// It should never really change unless we are in watch mode.
174+
const gqlSchema = await getGqlSchema()
175+
172176
// Run all of our middleware by converting them into promises and
173177
// chaining them together. Remember that if we have a middleware that
174178
// never calls `next`, we will have a promise that never resolves! Avoid
@@ -241,7 +245,7 @@ export default function createPostGraphQLHttpRequestHandler (options) {
241245
// Catch an errors while parsing so that we can set the `statusCode` to
242246
// 400. Otherwise we don’t need to parse this way.
243247
try {
244-
queryDocumentAST = parseGraphql(source)
248+
queryDocumentAst = parseGraphql(source)
245249
}
246250
catch (error) {
247251
res.statusCode = 400
@@ -252,7 +256,7 @@ export default function createPostGraphQLHttpRequestHandler (options) {
252256

253257
// Validate our GraphQL query using given rules.
254258
// TODO: Add a complexity GraphQL rule.
255-
const validationErrors = validateGraphql(await graphqlSchema, queryDocumentAST)
259+
const validationErrors = validateGraphql(gqlSchema, queryDocumentAst)
256260

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

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

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

284288
try {
285289
result = await executeGraphql(
286-
await graphqlSchema,
287-
queryDocumentAST,
290+
gqlSchema,
291+
queryDocumentAst,
288292
null,
289293
{ [$$pgClient]: pgClient },
290294
params.variables,
@@ -321,8 +325,8 @@ export default function createPostGraphQLHttpRequestHandler (options) {
321325
debugRequest('GraphQL query request finished.')
322326

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

0 commit comments

Comments
 (0)