graphile-build-pg
Version:
Build a GraphQL schema by reflection over a PostgreSQL schema. Easy to customize since it's built with plugins on graphile-build
567 lines (544 loc) • 22.1 kB
Flow
// @flow
import QueryBuilder from "./QueryBuilder";
import type { QueryBuilderOptions } from "./QueryBuilder";
import type { RawAlias } from "./QueryBuilder";
import * as sql from "pg-sql2";
import type { SQL } from "pg-sql2";
import type { DataForType } from "graphile-build";
import isSafeInteger from "lodash/isSafeInteger";
import assert from "assert";
import { inspect } from "util";
// eslint-disable-next-line flowtype/no-weak-types
type GraphQLContext = any;
const identity = _ => _ !== null && _ !== undefined;
function assertSafeName(name: mixed) {
if (typeof name !== "string") {
throw new Error(
`Expected name to be a string; instead received '${inspect(name)}'`
);
}
if (!/^[@a-zA-Z0-9_]{1,63}$/.test(name)) {
throw new Error(
`Name '${name}' is not safe - either it is too long, too short, or has invalid characters`
);
}
}
export default (queryBuilderOptions: QueryBuilderOptions = {}) =>
(
from: SQL,
fromAlias: ?SQL,
resolveData: DataForType,
inOptions: {
withPagination?: boolean,
withPaginationAsFields?: boolean,
asJson?: boolean,
asJsonAggregate?: boolean,
addNullCase?: boolean,
addNotDistinctFromNullCase?: boolean,
onlyJsonField?: boolean,
useAsterisk?: boolean,
withCursor?: boolean,
},
// TODO:v5: context is not optional
withBuilder?: ((builder: QueryBuilder) => void) | null | void,
context?: GraphQLContext = {},
rootValue?: any // eslint-disable-line flowtype/no-weak-types
) => {
const {
pgQuery,
pgAggregateQuery, // Shorthand for using pgNamedQueryContainer/pgNamedQuery combo
pgNamedQueryContainer = [],
pgNamedQuery = [],
pgCursorPrefix: reallyRawCursorPrefix,
pgDontUseAsterisk,
calculateHasNextPage,
calculateHasPreviousPage,
usesCursor: explicitlyUsesCursor,
} = resolveData;
// Push a query container for aggregates
if ((pgAggregateQuery && pgAggregateQuery.length) || pgNamedQuery.length) {
pgNamedQueryContainer.push({
name: "aggregates",
query: ({ queryBuilder, options, innerQueryBuilder }) => sql.fragment`\
(
select ${innerQueryBuilder.build({ onlyJsonField: true })}
from ${queryBuilder.getTableExpression()} as ${queryBuilder.getTableAlias()}
where ${queryBuilder.buildWhereClause(false, false, options)}
)`,
});
}
// Convert pgAggregateQuery to pgNamedQueryContainer/pgNamedQuery combo
if (pgAggregateQuery && pgAggregateQuery.length) {
// And a query for each previous query
pgAggregateQuery.forEach(query => {
pgNamedQuery.push({ name: "aggregates", query });
});
}
const preventAsterisk = pgDontUseAsterisk
? pgDontUseAsterisk.length > 0
: false;
const options = {
...inOptions,
// Allow pgDontUseAsterisk to override useAsterisk
useAsterisk: inOptions.useAsterisk && !preventAsterisk,
};
const usesCursor: boolean =
(explicitlyUsesCursor && explicitlyUsesCursor.length > 0) ||
(calculateHasNextPage && calculateHasNextPage.length > 0) ||
(calculateHasPreviousPage && calculateHasPreviousPage.length > 0) ||
false;
const rawCursorPrefix =
reallyRawCursorPrefix && reallyRawCursorPrefix.filter(identity);
const queryBuilder = new QueryBuilder(
queryBuilderOptions,
context,
rootValue
);
queryBuilder.from(from, fromAlias ? fromAlias : undefined);
if (withBuilder) {
withBuilder(queryBuilder);
}
if (pgQuery) {
for (let i = 0, l = pgQuery.length; i < l; i++) {
pgQuery[i](queryBuilder, resolveData);
}
}
function generateNextPrevPageSql(
sqlQueryAlias,
canHaveCursorInWhere,
queryHasBefore,
queryHasFirst,
offset = 0,
invert = false
) {
/*
* Strap in, 'coz this function gets hairy!
*
* The point of this function is to return SQL which will resolve to a
* boolean true/false depending on whether or not there is a (invert ?
* "previous" : "next") page.
*
* Connections have before, after, first, last and offset.
* - Users are forbidden from setting both first and last.
* - Users are forbidden from setting both offset and last.
*
* Further there are two main modes of paginating, one works by adding a
* where clause (this is preferred, but is not always possible, and is
* indicated by `canHaveCursorInWhere === true`) and the other works using
* standard LIMIT/OFFSET SQL pagination (and is indicated by
* `canHaveCursorInWhere === false`).
*
* The following diagram shows a full collection of records, #, starting at
* START and ending at END. The key after, before, offset, first and last
* variables are shown. One thing not show is that it's possible to have
* fewer records between before and after than requested by first or last.
* Another detail not clearly show is that if there is no `after` then
* `START` is used, similarly if there is no `before` then `END` is used.
*
* #################################################### < collection
* ^ ^<-offset->^<-first->^ ^<-last->^ ^
* | | | | | | |
* | | +---------+ +--------+ |
* | | | DATA1 DATA2 | |
* | | | | |
* | | | | |
* | | +-------------------------+ |
* | | DATA3 | |
* | after before |
* | |
* START END
*
* We want one of the three DATA blocks:
*
* - If `first` is set, then we want DATA1.
* - If `last` is set then we want DATA2.
* - If neither is set then we want DATA3.
*
* (Remember: you cannot set both `first` and `last` at the same time.)
*
* When invert === false:
*
* Note that both DATA2 and DATA3 end at the same point, and we only care
* if there's data *after* the relevant DATA block, so really we only
* care if the query specified `first` (`queryHasFirst`) which makes
* things complex (ending at the end of DATA1), otherwise we can use
* `before` as the bound (end of DATA2/DATA3).
*
* When invert === true:
*
* Similarly, DATA1 and DATA3 start at the same point, and we're going
* backwards so we only care if there's data *before* the DATA block, so
* really we just need to know if the query set `last` or not, but since
* this is inverted we call it `queryHasFirst`.
*
* When `invert` is false we're calculating `hasNextPage`, when true we're
* calculating `hasPreviousPage`.
*
* Because of the near-symmetry of requesting hasPreviousPage vs
* hasNextPage we always pretend we're determining `hasNextPage`, and we
* just invert everything.
*/
const sqlCommonUnbounded = sql.fragment`\
select 1
from ${queryBuilder.getTableExpression()} as ${queryBuilder.getTableAlias()}`;
/*
* This variable is a fragment to go into an `EXISTS(...)` call (after some tweaks).
*
* The buildWhereClause takes three arguments:
*
* - includeLowerBound (we want this for hasNextPage but not hasPreviousPage)
* - includeUpperBound (we want this for hasPreviousPage but not hasNextPage)
* - options (specifically `{addNullCase, addNotDistinctFromNullCase}`) -
* we just pass this through.
*
* So in hasNextPage mode (invert === false), this common SQL ends up
* representing the collection from `(after || START)` onwards with no
* upper bound. In hasPreviousPage mode (invert === true), it represents
* everything from `(before || END)` backwards, with no lower bound.
*/
const sqlCommon = sql.fragment`\
${sqlCommonUnbounded}
where ${queryBuilder.buildWhereClause(!invert, invert, options)}`;
/*
* Since the offset makes the diagram asymmetric, if offset === 0
* then the diagram is symmetric and things are simplified a little.
*/
const isForwardOrSymmetric = !invert || offset === 0;
if (!isForwardOrSymmetric) {
assert(invert);
assert(offset > 0);
// We're looking for a previous page, and there's an offset, so lets just
// assume there's a previous page where offset is smaller.
return sql.literal(true);
} else if (canHaveCursorInWhere) {
assert(isForwardOrSymmetric);
if (!queryHasBefore && !queryHasFirst) {
assert(isForwardOrSymmetric);
// There can be no next page since there's no upper bound
return sql.literal(false);
} else if (queryHasBefore && !queryHasFirst) {
/*
* We invert the upper buildWhereBoundClause to only represent the data
* after `before`, then check if there's at least one record in that set.
*
* This only works if the `before` cursor can be represented in the
* SQL WHERE clause, otherwise we're doing limit/offset pagination
* which requires different logic. It also only works if there's no
* `first` clause, otherwise there could be a next page before the
* `before` clause.
*/
return sql.fragment`\
exists(
${sqlCommonUnbounded}
where ${queryBuilder.buildWhereClause(false, false, options)}
and not (${queryBuilder.buildWhereBoundClause(invert)})
)`;
} else {
assert(queryHasFirst);
// queryHasBefore could be true or false.
/*
* There's a few ways that we could determine if there's a next page.
*
* If !queryHasBefore, we could COUNT(*) the number of rows in
* `sqlCommon` and see if it's larger than `first`:
* `(select count(*) > ${first} from (${sqlCommon}) __random_table_alias__)`
*
* If !queryHasBefore, we could build a subquery table of offsetData
* from sqlCommon and see if it contains any rows:
* `EXISTS(select 1 from (${sqlCommon} OFFSET ${first}) __random_table_alias__)`.
*
* We could see if there's at least one row in sqlCommon that's not
* already in our chosen result set.
*
* We've chosen the latter approach here because it doesn't place a limit
* on queryHasBefore.
*/
// Drop the `first` limit, see if there are any records that aren't
// already in the list we've fetched.
return sql.fragment`\
exists(
${sqlCommon}
and (${queryBuilder.getSelectCursor()})::text not in (select __cursor::text from ${sqlQueryAlias})
${offset === 0 ? sql.blank : sql.fragment`offset ${sql.value(offset)}`}
)`;
}
} else {
assert(!invert || offset === 0); // isForwardOrSymmetric
assert(!canHaveCursorInWhere);
// We're dealing with LIMIT/OFFSET pagination here, which means `natural`
// cursors, so the `queryBuilder` factors the before/after, first/last
// into the limit / offset.
const { limit } = queryBuilder.getFinalLimitAndOffset();
if (limit == null) {
// If paginating backwards, then offset > 0 has already been dealt
// with. Unbounded, so there's no next page.
return sql.fragment`false`;
} else if (invert) {
assert(offset === 0);
// Paginating backwards and there's no offset (which factors in before/after), so there's no previous page.
return sql.fragment`false`;
} else {
assert(!invert);
/*
* We're paginating forwards; either there's a before, there's a first,
* or both.
*
* We want to see if there's more than limit+offset records in sqlCommon.
*/
return sql.fragment`\
exists(
${sqlCommon}
offset ${sql.literal(limit + offset)}
)`;
}
}
}
const getPgCursorPrefix = () =>
rawCursorPrefix && rawCursorPrefix.length > 0
? rawCursorPrefix
: queryBuilder.data.cursorPrefix.map(val => sql.literal(val));
if (
options.withPagination ||
options.withPaginationAsFields ||
options.withCursor
) {
// Sometimes we need a __cursor even if it's not a collection; e.g. to get the edge field on a mutation
if (usesCursor) {
queryBuilder.selectCursor((): SQL => {
const orderBy = queryBuilder
.getOrderByExpressionsAndDirections()
.map(([expr]) => expr);
if (queryBuilder.isOrderUnique() && orderBy.length > 0) {
return sql.fragment`json_build_array(${sql.join(
[
...getPgCursorPrefix(),
sql.fragment`json_build_array(${sql.join(orderBy, ", ")})`,
],
", "
)})`;
} else {
return sql.fragment`json_build_array(${sql.join(
getPgCursorPrefix(),
", "
)}, ${
/*
* NOTE[useAsterisk/row_number]: If we have useAsterisk then the
* query with limit offset is in a subquery, so our row_number()
* call doesn't know about it. Here we add the offset back in
* again. See matching NOTE in QueryBuilder.js.
*/
options.useAsterisk
? sql.fragment`${sql.literal(
queryBuilder.getFinalOffset() || 0
)} + `
: sql.fragment``
}(row_number() over (partition by 1)))`;
}
});
}
}
if (options.withPagination || options.withPaginationAsFields) {
queryBuilder.setCursorComparator((cursorValue, isAfter) => {
function badCursor() {
queryBuilder.whereBound(sql.fragment`false`, isAfter);
}
const orderByExpressionsAndDirections =
queryBuilder.getOrderByExpressionsAndDirections();
if (orderByExpressionsAndDirections.length > 0) {
if (!queryBuilder.isOrderUnique()) {
throw new Error(
"The order supplied is not unique, so before/after cursors cannot be used. Please ensure the supplied order includes all the columns from the primary key or a unique constraint."
);
}
const rawPrefixes = cursorValue.slice(0, cursorValue.length - 1);
const rawCursors = cursorValue[cursorValue.length - 1];
if (rawPrefixes.length !== getPgCursorPrefix().length) {
badCursor();
return;
}
if (!Array.isArray(rawCursors)) {
badCursor();
return;
}
let sqlFilter = sql.fragment`false`;
const sqlCursors = rawCursors.map(val => sql.value(val));
for (
let i = orderByExpressionsAndDirections.length - 1;
i >= 0;
i--
) {
const [sqlExpression, ascending] =
orderByExpressionsAndDirections[i];
// If ascending and isAfter then >
// If ascending and isBefore then <
const comparison =
Number(ascending) ^ Number(!isAfter)
? sql.fragment`>`
: sql.fragment`<`;
const sqlOldFilter = sqlFilter;
sqlFilter = sql.fragment`\
(\
(${sqlExpression} ${comparison} ${sqlCursors[i] || sql.null})
OR\
(\
${sqlExpression} = ${sqlCursors[i] || sql.null}\
AND\
${sqlOldFilter}\
)\
)`;
}
// Check the cursor prefixes apply
// TODO:v5: we should be able to do this in JS-land rather than SQL-land
sqlFilter = sql.fragment`(((${sql.join(
getPgCursorPrefix(),
", "
)}) = (${sql.join(
rawPrefixes.map(val => sql.value(val)),
", "
)})) AND (${sqlFilter}))`;
queryBuilder.whereBound(sqlFilter, isAfter);
} else if (
cursorValue[0] === "natural" &&
isSafeInteger(cursorValue[1]) &&
// $FlowFixMe: we know this is a number
cursorValue[1] >= 0
) {
// $FlowFixMe: we know this is a number
const cursorValue1: number = cursorValue[1];
if (isAfter) {
queryBuilder.offset(() => cursorValue1);
} else {
queryBuilder.limit(() => {
const offset = queryBuilder.getOffset();
return Math.max(0, cursorValue1 - offset - 1);
});
}
} else {
throw new Error(
"Cannot use 'before'/'after' without unique 'orderBy'"
);
}
});
const query = queryBuilder.build(options);
const haveFields = queryBuilder.getSelectFieldsCount() > 0;
const sqlQueryAlias = sql.identifier(Symbol());
const sqlSummaryAlias = sql.identifier(Symbol());
//
// Tables should ALWAYS push their PK onto the order stack, if this isn't
// present then we're either dealing with a view or a table without a PK.
// Either way, we don't have anything to guarantee uniqueness so we need to
// fall back to limit/offset.
//
// TODO: support unique keys in PgAllRows etc
// TODO: add a warning for cursor-based pagination when using the fallback
// TODO: if it is a view maybe add a warning encouraging pgViewUniqueKey
const canHaveCursorInWhere =
queryBuilder.getOrderByExpressionsAndDirections().length > 0 &&
queryBuilder.isOrderUnique();
const queryHasBefore =
queryBuilder.compiledData.whereBound.upper.length > 0;
const queryHasAfter =
queryBuilder.compiledData.whereBound.lower.length > 0;
const queryHasZeroLimit = queryBuilder.getFinalLimit() === 0;
const queryHasFirst = isSafeInteger(queryBuilder.compiledData.first);
const queryHasLast = isSafeInteger(queryBuilder.compiledData.last);
const hasNextPage = queryHasZeroLimit
? sql.literal(false)
: generateNextPrevPageSql(
sqlQueryAlias,
canHaveCursorInWhere,
queryHasBefore,
queryHasFirst,
queryBuilder.getFinalOffset() || 0
);
const hasPreviousPage = queryHasZeroLimit
? sql.literal(false)
: generateNextPrevPageSql(
sqlQueryAlias,
canHaveCursorInWhere,
queryHasAfter,
queryHasLast,
queryBuilder.getFinalOffset() || 0,
true
);
const sqlWith = haveFields
? sql.fragment`with ${sqlQueryAlias} as (${query}), ${sqlSummaryAlias} as (select json_agg(to_json(${sqlQueryAlias})) as data from ${sqlQueryAlias})`
: sql.fragment``;
const sqlFrom = sql.fragment``;
const fields: Array<[SQL, RawAlias]> = [];
if (haveFields) {
fields.push([
sql.fragment`coalesce((select ${sqlSummaryAlias}.data from ${sqlSummaryAlias}), '[]'::json)`,
"data",
]);
if (calculateHasNextPage) {
fields.push([hasNextPage, "hasNextPage"]);
}
if (calculateHasPreviousPage) {
fields.push([hasPreviousPage, "hasPreviousPage"]);
}
}
if (pgNamedQuery && pgNamedQuery.length) {
const groups = {};
pgNamedQuery.forEach(({ name, query }) => {
assertSafeName(name);
if (!groups[name]) {
groups[name] = [];
}
groups[name].push(query);
});
Object.keys(groups).forEach(groupName => {
const queryCallbacks = groups[groupName];
// Get container
const containers = pgNamedQueryContainer.filter(
c => c.name === groupName
);
if (containers.length === 0) {
throw new Error(
`${queryCallbacks.length} pgNamedQuery entries with name: '${groupName}' existed, but there was no matching pgNamedQueryContainer.`
);
}
if (containers.length > 1) {
throw new Error(
`${containers.length} pgNamedQueryContainer entries with name: '${groupName}' existed, but there should be exactly one.`
);
}
const container = containers[0];
const innerQueryBuilder = new QueryBuilder(
queryBuilderOptions,
context,
rootValue
);
innerQueryBuilder.from(
queryBuilder.getTableExpression(),
queryBuilder.getTableAlias()
);
for (let i = 0, l = queryCallbacks.length; i < l; i++) {
queryCallbacks[i](innerQueryBuilder);
}
// Generate the SQL statement (e.g. `select ${innerQueryBuilder.build({onlyJsonField: true})} from ${queryBuilder.getTableExpression()} as ...`)
const aggregatesSql = container.query({
queryBuilder,
innerQueryBuilder,
options,
});
fields.push([aggregatesSql, groupName]);
});
}
if (options.withPaginationAsFields) {
return sql.fragment`${sqlWith} select ${sql.join(
fields.map(
([expr, alias]) => sql.fragment`${expr} as ${sql.identifier(alias)}`
),
", "
)} ${sqlFrom}`;
} else {
return sql.fragment`${sqlWith} select ${queryBuilder.jsonbBuildObject(
fields
)} ${sqlFrom}`;
}
} else {
const query = queryBuilder.build(options);
return query;
}
};