UNPKG

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

718 lines (703 loc) 26.6 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.default = void 0; var sql = _interopRequireWildcard(require("pg-sql2")); var _isSafeInteger = _interopRequireDefault(require("lodash/isSafeInteger")); var _chunk = _interopRequireDefault(require("lodash/chunk")); function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function (nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); } function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; } // eslint-disable-next-line flowtype/no-weak-types const isDev = process.env.POSTGRAPHILE_ENV === "development"; // Importantly, this cannot include a function function callIfNecessary(o, context) { if (typeof o === "function") { return o(context); } else { return o; } } function callIfNecessaryArray(o, context) { if (Array.isArray(o)) { return o.map(v => callIfNecessary(v, context)); } else { return o; } } function escapeLarge(sqlFragment, type) { const actualType = type.domainBaseType || type; if (actualType.category === "N") { if (["21" /* int2 */, "23" /* int4 */, "700" /* float4 */, "701" /* float8 */, "24" /* regproc */, "2202" /* regprocedure */, "2203" /* regoper */, "2204" /* regoperator */, "2205" /* regclass */, "2206" /* regtype */, "4096" /* regrole */, "4089" /* regnamespace */, "3734" /* regconfig */, "3769" /* regdictionary */].includes(actualType.id)) { // No need for special handling return sqlFragment; } // Otherwise force the id to be a string return sql.fragment`((${sqlFragment})::numeric)::text`; } return sqlFragment; } class QueryBuilder { // eslint-disable-line flowtype/no-weak-types constructor(options = {}, context = {}, rootValue) { this.context = context || {}; this.rootValue = rootValue; this.supportsJSONB = typeof options.supportsJSONB === "undefined" || options.supportsJSONB === null ? true : !!options.supportsJSONB; this.locks = { // As a performance optimisation, we're going to list a number of lock // types so that V8 doesn't need to mutate the object too much cursorComparator: false, fixedSelectExpression: false, select: false, selectCursor: false, from: false, join: false, whereBound: false, where: false, orderBy: false, orderIsUnique: false, first: false, last: false, limit: false, offset: false }; this.finalized = false; this.selectedIdentifiers = false; this.data = { // TODO: refactor `cursorPrefix`, it shouldn't be here (or should at least have getters/setters) cursorPrefix: ["natural"], fixedSelectExpression: null, select: [], selectCursor: null, from: null, join: [], where: [], whereBound: { lower: [], upper: [] }, orderBy: [], orderIsUnique: false, limit: null, offset: null, first: null, last: null, beforeLock: { // As a performance optimisation, we're going to list a number of lock // types so that V8 doesn't need to mutate the object too much cursorComparator: [], fixedSelectExpression: [], select: [], selectCursor: [], from: [], join: [], whereBound: [], where: [], orderBy: [], orderIsUnique: [], first: [], last: [], limit: [], offset: [] }, cursorComparator: null, liveConditions: [] }; this.compiledData = { cursorPrefix: ["natural"], fixedSelectExpression: null, select: [], selectCursor: null, from: null, join: [], where: [], whereBound: { lower: [], upper: [] }, orderBy: [], orderIsUnique: false, limit: null, offset: null, first: null, last: null, cursorComparator: null }; this._children = new Map(); this.beforeLock("select", () => { this.lock("selectCursor"); if (this.compiledData.selectCursor) { this.select(this.compiledData.selectCursor, "__cursor"); } }); // 'whereBound' and 'natural' order might set offset/limit this.beforeLock("where", () => { this.lock("whereBound"); }); this.beforeLock("offset", () => { this.lock("whereBound"); }); this.beforeLock("limit", () => { this.lock("whereBound"); }); this.beforeLock("first", () => { this.lock("limit"); this.lock("offset"); }); this.beforeLock("last", () => { this.lock("limit"); this.lock("offset"); }); this.lockContext = Object.freeze({ queryBuilder: this }); } // ---------------------------------------- // Helper function jsonbBuildObject(fields) { if (this.supportsJSONB && fields.length > 50) { const fieldsChunks = (0, _chunk.default)(fields, 50); const chunkToJson = fieldsChunk => sql.fragment`jsonb_build_object(${sql.join(fieldsChunk.map(([expr, alias]) => sql.fragment`${sql.literal(alias)}::text, ${expr}`), ", ")})`; return sql.fragment`(${sql.join(fieldsChunks.map(chunkToJson), " || ")})::json`; } else { // PG9.4 will have issues with more than 100 parameters (50 keys) return sql.fragment`json_build_object(${sql.join(fields.map(([expr, alias]) => sql.fragment`${sql.literal(alias)}::text, ${expr}`), ", ")})`; } } // ---------------------------------------- beforeLock(field, fn) { this.checkLock(field); if (!this.data.beforeLock[field]) { this.data.beforeLock[field] = []; } // $FlowFixMe: this is guaranteed to be set, due to the if statement above this.data.beforeLock[field].push(fn); } makeLiveCollection(table, // eslint-disable-next-line flowtype/no-weak-types cb) { /* the actual condition doesn't matter hugely, 'select' should work */ if (!this.rootValue || !this.rootValue.liveConditions) return; const liveConditions = this.data.liveConditions; const checkerGenerator = data => { // Compute this once. const checkers = liveConditions.map(([checkerGenerator]) => checkerGenerator(data)); return record => checkers.every(checker => checker(record)); }; if (this.parentQueryBuilder) { const parentQueryBuilder = this.parentQueryBuilder; if (cb) { throw new Error("Either use parentQueryBuilder or pass callback, not both."); } parentQueryBuilder.beforeLock("select", () => { const id = this.rootValue.liveConditions.push(checkerGenerator) - 1; // BEWARE: it's easy to override others' conditions, and that will cause issues. Be sensible. const allRequirements = this.data.liveConditions.reduce((memo, [_checkerGenerator, requirements]) => requirements ? Object.assign(memo, requirements) : memo, {}); parentQueryBuilder.select(sql.fragment`\ json_build_object('__id', ${sql.value(id)}::int ${sql.join(Object.keys(allRequirements).map(key => sql.fragment`, ${sql.literal(key)}::text, ${allRequirements[key]}`), "")})`, "__live"); }); } else if (cb) { cb(checkerGenerator); } else { throw new Error("makeLiveCollection was called without parentQueryBuilder and without callback"); } } addLiveCondition( // eslint-disable-next-line flowtype/no-weak-types checkerGenerator, requirements) { if (requirements && !this.parentQueryBuilder) { throw new Error("There's no parentQueryBuilder, so there cannot be requirements"); } this.data.liveConditions.push([checkerGenerator, requirements]); } setCursorComparator(fn) { this.checkLock("cursorComparator"); this.data.cursorComparator = fn; this.lock("cursorComparator"); } addCursorCondition(cursorValue, isAfter) { this.beforeLock("whereBound", () => { this.lock("cursorComparator"); if (!this.compiledData.cursorComparator) { throw new Error("No cursor comparator was set!"); } this.compiledData.cursorComparator(cursorValue, isAfter); }); } /** this method is experimental */ fixedSelectExpression(exprGen) { this.checkLock("fixedSelectExpression"); this.lock("select"); this.lock("selectCursor"); if (this.data.select.length > 0) { throw new Error("Cannot use .fixedSelectExpression() with .select()"); } if (this.data.selectCursor) { throw new Error("Cannot use .fixedSelectExpression() with .selectCursor()"); } this.data.fixedSelectExpression = exprGen; } select(exprGen, alias) { this.checkLock("select"); this.lock("fixedSelectExpression"); if (typeof alias === "string") { // To protect against vulnerabilities such as // // https://github.com/brianc/node-postgres/issues/1408 // // we need to ensure column names are safe. Turns out that GraphQL // aliases are fairly strict (`[_A-Za-z][_0-9A-Za-z]*`) anyway: // // https://github.com/graphql/graphql-js/blob/680685dd14bd52c6475305e150e5f295ead2aa7e/src/language/lexer.js#L551-L581 // // so this should not cause any issues in practice. if (/^(\$+|@+|[_A-Za-z])[_0-9A-Za-z]*$/.test(alias) !== true) { throw new Error(`Disallowed alias '${alias}'.`); } } this.data.select.push([exprGen, alias]); } selectIdentifiers(table) { if (this.selectedIdentifiers) return; const primaryKey = table.primaryKeyConstraint; if (!primaryKey) return; const primaryKeys = primaryKey.keyAttributes; this.select(sql.fragment`json_build_array(${sql.join(primaryKeys.map(key => escapeLarge(sql.fragment`${this.getTableAlias()}.${sql.identifier(key.name)}`, key.type)), ", ")})`, "__identifiers"); this.selectedIdentifiers = true; } selectCursor(exprGen) { this.checkLock("selectCursor"); this.lock("fixedSelectExpression"); this.data.selectCursor = exprGen; } from(expr, alias = sql.identifier(Symbol())) { this.checkLock("from"); if (!expr) { throw new Error("No from table source!"); } if (!alias) { throw new Error("No from alias!"); } this.data.from = [expr, alias]; this.lock("from"); } // XXX: join where(exprGen) { this.checkLock("where"); this.data.where.push(exprGen); } whereBound(exprGen, isLower) { if (typeof isLower !== "boolean") { throw new Error("isLower must be specified as a boolean"); } this.checkLock("whereBound"); this.data.whereBound[isLower ? "lower" : "upper"].push(exprGen); } setOrderIsUnique() { this.data.orderIsUnique = true; } orderBy(exprGen, ascending = true, nullsFirst = null) { this.checkLock("orderBy"); this.data.orderBy.push([exprGen, ascending, nullsFirst]); } limit(limitGen) { this.checkLock("limit"); if (this.data.limit != null) { throw new Error("Must only set limit once"); } this.data.limit = limitGen; } offset(offsetGen) { this.checkLock("offset"); if (this.data.offset != null) { // Add the offsets together (this should be able to recurse) const previous = this.data.offset; this.data.offset = context => { return callIfNecessary(previous, context) + callIfNecessary(offsetGen, context); }; } else { this.data.offset = offsetGen; } } first(first) { this.checkLock("first"); if (this.data.first != null) { throw new Error("Must only set first once"); } this.data.first = first; } last(last) { this.checkLock("last"); if (this.data.last != null) { throw new Error("Must only set last once"); } this.data.last = last; } // ---------------------------------------- isOrderUnique(lock = true) { if (lock) { this.lock("orderBy"); this.lock("orderIsUnique"); return this.compiledData.orderIsUnique; } else { // This is useful inside `beforeLock("orderBy", ...)` calls return this.data.orderIsUnique; } } getTableExpression() { this.lock("from"); if (!this.compiledData.from) { throw new Error("No from table has been supplied"); } return this.compiledData.from[0]; } getTableAlias() { this.lock("from"); if (!this.compiledData.from) { throw new Error("No from table has been supplied"); } return this.compiledData.from[1]; } getSelectCursor() { this.lock("selectCursor"); return this.compiledData.selectCursor; } getOffset() { this.lock("offset"); return this.compiledData.offset || 0; } getFinalLimitAndOffset() { this.lock("offset"); this.lock("limit"); this.lock("first"); this.lock("last"); let limit = this.compiledData.limit; let offset = this.compiledData.offset || 0; let flip = false; if (this.compiledData.first != null) { if (limit != null) { limit = Math.min(limit, this.compiledData.first); } else { limit = this.compiledData.first; } } if (this.compiledData.last != null) { if (offset > 0 && limit != null) { throw new Error("Issue within pagination, please report your query to graphile-build"); } if (limit != null) { if (this.compiledData.last < limit) { offset = limit - this.compiledData.last; limit = this.compiledData.last; } else { // no need to change anything } } else if (offset > 0) { throw new Error("Cannot combine 'last' and 'offset'"); } else { if (this.compiledData.orderBy.length > 0) { flip = true; limit = this.compiledData.last; } else { throw new Error("Cannot do last of an unordered set"); } } } return { limit, offset, flip }; } getFinalOffset() { return this.getFinalLimitAndOffset().offset; } getFinalLimit() { return this.getFinalLimitAndOffset().limit; } getOrderByExpressionsAndDirections() { this.lock("orderBy"); return this.compiledData.orderBy; } getSelectFieldsCount() { this.lockEverything(); return this.compiledData.select.length; } buildSelectFields() { this.lockEverything(); if (this.compiledData.fixedSelectExpression) { return this.compiledData.fixedSelectExpression; } return sql.join(this.compiledData.select.map(([sqlFragment, alias]) => sql.fragment`to_json(${sqlFragment}) as ${sql.identifier(alias)}`), ", "); } buildSelectJson({ addNullCase, addNotDistinctFromNullCase }) { this.lockEverything(); let buildObject = this.compiledData.select.length ? this.jsonbBuildObject(this.compiledData.select) : sql.fragment`to_json(${this.getTableAlias()})`; if (addNotDistinctFromNullCase) { /* * `is null` is not sufficient here because the record might exist but * have null as each of its values; so we use `is not distinct from null` * to assert that the record itself doesn't exist. This is typically used * with column values. */ buildObject = sql.fragment`(case when (${this.getTableAlias()} is not distinct from null) then null else ${buildObject} end)`; } else if (addNullCase) { /* * `is null` is probably used here because it's the result of a function; * functions seem to have trouble differentiating between `null::my_type` * and `(null,null,null)::my_type`, always opting for the latter which * then causes issues with the `GraphQLNonNull`s in the schema. */ buildObject = sql.fragment`(case when (${this.getTableAlias()} is null) then null else ${buildObject} end)`; } return buildObject; } buildWhereBoundClause(isLower) { this.lock("whereBound"); const clauses = this.compiledData.whereBound[isLower ? "lower" : "upper"]; if (clauses.length) { return sql.fragment`(${sql.join(clauses, ") and (")})`; } else { return sql.literal(true); } } buildWhereClause(includeLowerBound, includeUpperBound, { addNullCase, addNotDistinctFromNullCase }) { this.lock("where"); const clauses = [ /* * Okay... so this is quite interesting. When we're talking about * composite types, `(foo is not null)` and `not (foo is null)` are NOT * equivalent! Here's why: * * `(foo is null)` * true if every field of the row is null * * `(foo is not null)` * true if every field of the row is not null * * `not (foo is null)` * true if there's at least one field that is not null * * `is [not] distinct from null` does differentiate between these cases, * but when a function returns something like `select * from my_table * where false`, it actually returns `(null, null, null)::my_table`, * which will cause issues when we apply the `GraphQLNonNull` constraints * to the results - we want to treat this as null. * * So don't "simplify" the line below! We're probably checking if the * result of a function call returning a compound type was indeed null. */ ...(addNotDistinctFromNullCase ? [sql.fragment`(${this.getTableAlias()} is distinct from null)`] : addNullCase ? [sql.fragment`not (${this.getTableAlias()} is null)`] : []), ...this.compiledData.where, ...(includeLowerBound ? [this.buildWhereBoundClause(true)] : []), ...(includeUpperBound ? [this.buildWhereBoundClause(false)] : [])]; return clauses.length ? sql.fragment`(${sql.join(clauses, ") and (")})` : sql.fragment`1 = 1`; } build(options = {}) { this.lockEverything(); if (this.compiledData.fixedSelectExpression) { if (Object.keys(options).length > 0) { throw new Error("Do not pass options to QueryBuilder.build() when using `buildNamedChildSelecting`"); } } const { asJson = false, asJsonAggregate = false, onlyJsonField = false, addNullCase = false, addNotDistinctFromNullCase = false, useAsterisk = false } = options; if (onlyJsonField) { return this.buildSelectJson({ addNullCase, addNotDistinctFromNullCase }); } const { limit, offset, flip } = this.getFinalLimitAndOffset(); const fields = asJson || asJsonAggregate ? sql.fragment`${this.buildSelectJson({ addNullCase, addNotDistinctFromNullCase })} as object` : this.buildSelectFields(); let fragment = sql.fragment`\ select ${useAsterisk ? sql.fragment`${this.getTableAlias()}.*` : fields} ${this.compiledData.from && sql.fragment`from ${this.compiledData.from[0]} as ${this.getTableAlias()}`} ${this.compiledData.join.length && sql.join(this.compiledData.join, " ")} where ${this.buildWhereClause(true, true, options)} ${this.compiledData.orderBy.length ? sql.fragment`order by ${sql.join(this.compiledData.orderBy.map(([expr, ascending, nullsFirst]) => sql.fragment`${expr} ${Number(ascending) ^ Number(flip) ? sql.fragment`ASC` : sql.fragment`DESC`}${nullsFirst === true ? sql.fragment` NULLS FIRST` : nullsFirst === false ? sql.fragment` NULLS LAST` : null}`), ",")}` : ""} ${(0, _isSafeInteger.default)(limit) && sql.fragment`limit ${sql.literal(limit)}`} ${offset && sql.fragment`offset ${sql.literal(offset)}`}`; if (flip) { const flipAlias = Symbol(); fragment = sql.fragment`\ with ${sql.identifier(flipAlias)} as ( ${fragment} ) select * from ${sql.identifier(flipAlias)} order by (row_number() over (partition by 1)) desc`; /* We don't need to factor useAsterisk into this row_number() usage */ } if (useAsterisk) { /* * NOTE[useAsterisk/row_number]: since LIMIT/OFFSET is inside this * subquery, row_number() outside of this subquery WON'T include the * offset. We must add it back wherever row_number() is used. */ fragment = sql.fragment`select ${fields} from (${fragment}) ${this.getTableAlias()}`; } if (asJsonAggregate) { const aggAlias = Symbol(); fragment = sql.fragment`select json_agg(${sql.identifier(aggAlias, "object")}) from (${fragment}) as ${sql.identifier(aggAlias)}`; fragment = sql.fragment`select coalesce((${fragment}), '[]'::json)`; } return fragment; } // ---------------------------------------- _finalize() { this.finalized = true; } lock(type) { if (this.locks[type]) return; const context = this.lockContext; const { beforeLock } = this.data; let locks = beforeLock[type]; if (locks) { beforeLock[type] = []; for (let i = 0, l = locks.length; i < l; i++) { locks[i](); } } if (type !== "select") { this.locks[type] = isDev ? new Error("Initally locked here").stack : true; } if (type === "cursorComparator") { // It's meant to be a function this.compiledData[type] = this.data[type]; } else if (type === "whereBound") { // Handle properties separately this.compiledData[type].lower = callIfNecessaryArray(this.data[type].lower, context); this.compiledData[type].upper = callIfNecessaryArray(this.data[type].upper, context); } else if (type === "fixedSelectExpression") { this.compiledData[type] = callIfNecessary(this.data[type], context); } else if (type === "select") { /* * NOTICE: locking select can cause additional selects to be added, so the * length of this.data[type] may increase during the operation. This is * why we handle this.locks[type] separately. */ // Assume that duplicate fields must be identical, don't output the same // key multiple times const seenFields = {}; const data = []; const selects = this.data[type]; // DELIBERATE slow loop, see NOTICE above for (let i = 0; i < selects.length; i++) { const [valueOrGenerator, columnName] = selects[i]; if (!seenFields[columnName]) { seenFields[columnName] = true; data.push([callIfNecessary(valueOrGenerator, context), columnName]); locks = beforeLock[type]; if (locks) { beforeLock[type] = []; for (let i = 0, l = locks.length; i < l; i++) { locks[i](); } } } } this.locks[type] = isDev ? new Error("Initally locked here").stack : true; this.compiledData[type] = data; } else if (type === "orderBy") { this.compiledData[type] = this.data[type].map(([a, b, c]) => [callIfNecessary(a, context), b, c]); } else if (type === "from") { if (this.data.from) { const f = this.data.from; this.compiledData.from = [callIfNecessary(f[0], context), f[1]]; } } else if (type === "join" || type === "where") { this.compiledData[type] = callIfNecessaryArray(this.data[type], context); } else if (type === "selectCursor") { this.compiledData[type] = callIfNecessary(this.data[type], context); } else if (type === "cursorPrefix") { this.compiledData[type] = this.data[type]; } else if (type === "orderIsUnique") { this.compiledData[type] = this.data[type]; } else if (type === "limit") { this.compiledData[type] = callIfNecessary(this.data[type], context); } else if (type === "offset") { this.compiledData[type] = callIfNecessary(this.data[type], context); } else if (type === "first") { this.compiledData[type] = this.data[type]; } else if (type === "last") { this.compiledData[type] = this.data[type]; } else { throw new Error(`Wasn't expecting to lock '${type}'`); } } checkLock(type) { if (this.locks[type]) { if (typeof this.locks[type] === "string") { throw new Error(`'${type}' has already been locked\n ` + this.locks[type].replace(/\n/g, "\n ") + "\n"); } throw new Error(`'${type}' has already been locked`); } } lockEverything() { this._finalize(); // We must execute everything after `from` so we have the alias to reference this.lock("from"); this.lock("join"); this.lock("orderBy"); // We must execute where after orderBy because cursor queries require all orderBy columns this.lock("cursorComparator"); this.lock("whereBound"); this.lock("where"); // 'where' -> 'whereBound' can affect 'offset'/'limit' this.lock("offset"); this.lock("limit"); this.lock("first"); this.lock("last"); // We must execute select after orderBy otherwise we cannot generate a cursor this.lock("fixedSelectExpression"); this.lock("selectCursor"); this.lock("select"); } /** this method is experimental */ buildChild() { const options = { supportsJSONB: this.supportsJSONB }; const child = new QueryBuilder(options, this.context, this.rootValue); child.parentQueryBuilder = this; return child; } /** this method is experimental */ buildNamedChildSelecting(name, from, selectExpression, alias) { if (this._children.has(name)) { throw new Error(`QueryBuilder already has a child named ${name.toString()}`); } const child = this.buildChild(); child.from(from, alias); child.fixedSelectExpression(selectExpression); this._children.set(name, child); return child; } /** this method is experimental */ getNamedChild(name) { return this._children.get(name); } } var _default = QueryBuilder; exports.default = _default; //# sourceMappingURL=QueryBuilder.js.map