UNPKG

massive

Version:

A small query tool for Postgres that embraces json and makes life simpler

241 lines (199 loc) 8.62 kB
'use strict'; const _ = require('lodash'); const parseKey = require('../util/parse-key'); const Statement = require('./statement'); /** * Represents a SELECT query. * * @class * @param {Readable} source - Database object to query. * @param {Object|String|UUID|Number} [criteria] - A criteria object, prebuilt * predicate, or primitive pk value. * @param {Object} [options] - {@link https://massivejs.org/docs/options-objects|Select options}. */ const Select = function (source, criteria, options = {}) { Statement.call(this, source, options); this.setCriteria(criteria); // options governing SQL statement elements, in rough order of appearance: this.distinct = options.distinct || false; this.selectList = this.buildSelectList(options.fields, options.exprs); this.order = _.reduce(options.order, (acc, val) => { const direction = val.direction && val.direction.toLowerCase() === 'desc' ? ' DESC' : ' ASC'; const nulls = val.nulls ? ` NULLS ${val.nulls === 'first' ? 'FIRST' : 'LAST'}` : ''; acc.push(this.buildOrderExpression(val, options.orderBody) + direction + nulls); return acc; }, []); this.offset = options.offset; this.limit = options.limit; this.pageLength = options.pageLength; this.lock = this.parseLock(options); // with pageLength set for keyset pagination, add last values of ordering // fields to criteria if (this.pageLength) { if (!options.order) { throw new Error('Keyset paging with pageLength requires an explicit order directive'); } else if ( Object.prototype.hasOwnProperty.call(options, 'offset') || Object.prototype.hasOwnProperty.call(options, 'limit') ) { throw new Error('Keyset paging cannot be used with offset and limit'); } else if (Object.hasOwnProperty.call(options.order[0], 'last')) { const paginationColumns = options.order.map(o => this.buildOrderExpression(o)).join(','); const placeholders = options.order.map((o, idx) => `$${idx + this.params.length + 1}`).join(','); const comparison = options.order[0].direction && options.order[0].direction.toLowerCase() === 'desc' ? '<' : '>'; this.params = this.params.concat(options.order.map(o => o.last)); this.pagination = `(${paginationColumns}) ${comparison} (${placeholders})`; } } }; Select.prototype = Object.create(Statement.prototype); /** * Build a list of strings comprising fields (plus aliases, for document * tables and joined or compound Readables) and expressions to be retrieved * from the source Readable. * * @param {Array|Object} fields - A list of field names, or a map of field * aliases to names. The map form may include the key-value pair `*: true` to * include unaliased columns as well. * @param {Object} exprs - A map of expression aliases to values. Values are * interpolated directly into the SQL emitted and are thus a potential vector * for SQL injection attacks if used carelessly. * @return {Array} The complete array of select expressions. */ Select.prototype.buildSelectList = function (fields, exprs) { const renamingFields = _.isPlainObject(fields); if (renamingFields && fields['*']) { // add in all this source's other columns not explicitly aliased const mapped = _.values(fields); fields = _.reduce(this.source.columns, (map, field) => { if (mapped.indexOf(field.name) === -1) { map[field.name] = field.name; } return map; }, _.omit(fields, '*')); } const selectList = _.reduceRight(renamingFields ? fields : _.castArray(fields || []), (all, field, alias) => { if (this.document) { // document fields need to alias a JSON traversal expression const documentField = `body.${field}`; all.unshift(`${parseKey(documentField, this.source).lhs} AS "${field}"`); } else { const lhs = parseKey(field, this.source).lhs; if (!renamingFields || lhs === `"${alias}"`) { all.unshift(lhs); } else { all.unshift(`${lhs} AS "${alias}"`); } } return all; }, _.map(exprs || {}, (expr, name) => { // interpolate unsafe user-defined expressions return `${expr} AS "${name}"`; })); if (!selectList.length) { if (!fields && !exprs) { // nothing specified, select all if (this.source.loader === 'join') { return this.source.columns.map(c => `${c.fullName} AS "${c.alias}"`); } return ['*']; } // we got nothing *explicitly*, error state throw new Error('At least one of fields or exprs, if supplied, must define a field or expression to select.'); } else if (this.document && fields) { // if we're querying fields on a document table, the id must always be // included. Expressions always need to be fully qualified, so expression- // only queries (e.g. countDoc) aren't affected here. selectList.unshift('"id"'); } return selectList; }; /** * Build a single expression for an ORDER BY list. * * @param {Object} orderObj - An object representing an ORDER BY list element. * @param {String} [orderObj.field] - The name of a field in the target * relation. May include JSON traversal or an implicit cast in the Postgres * 'x::y' format. Either field or expr must be supplied. * @param {String} [orderObj.expr] - An expression to be interpolated into the * ORDER BY clause directly. * @param {String} [orderObj.direction] - "ASC" or "DESC"; not used here, but * included for completeness. * @param {String} [orderObj.type] - An explicit cast type. If specified for a * JSON traversal expression, the value will be retrieved as text before it is * cast to the target type. * @param {Boolean} useBody - True to treat orderObj.field as an element in the * document body instead of a column on the target relation. * @return {String} A single expression to be included in an ORDER BY clause. */ Select.prototype.buildOrderExpression = function (orderObj, useBody = false) { const jsonAsText = !!orderObj.type; // Explicit casts must use as-text operators let field; if (orderObj.expr) { field = orderObj.expr; } else if (useBody) { field = `"body"${jsonAsText ? '->>' : '->'}'${orderObj.field}'`; } else if (orderObj.field) { field = parseKey(orderObj.field, this.source, jsonAsText).lhs; } else { throw new Error('Missing order field or expr.'); } if (orderObj.type) { return `(${field})::${orderObj.type}`; } return field; }; /** * Parse a query explicit locking options, supporting legacy `forUpdate` and `forShare` * and `forShare` arguments * @param {Object} [options] - {@link https://massivejs.org/docs/options-objects|Select options} * @return {Object} a lock object */ Select.prototype.parseLock = function (options) { // TODO remove forUpdate and forShare in v7 const {forUpdate, forShare, lock} = options; // fail if more than one of those options is not null and not undefined if ([forShare, forUpdate, lock].filter((x) => x != null).length > 1) { throw new Error('The "forUpdate", "forShare", and "lock" options are mutually exclusive'); } if (forUpdate !== undefined || forShare !== undefined && process.env.NODE_ENV !== 'production') { /* eslint-disable-next-line no-console */ console.log('DEPRECATED: the "forShare" and "forUpdate" options are deprecated and will be removed in a future version. Use "lock" instead.'); } if (forUpdate) { return {strength: 'UPDATE'}; } if (forShare) { return {strength: 'SHARE'}; } return options.lock; }; /** * Format this object into a SQL SELECT. * * @return {String} A SQL SELECT statement. */ Select.prototype.format = function () { let sql = 'SELECT '; if (this.distinct) { sql += 'DISTINCT '; } sql += `${this.selectList.join(',')} FROM `; if (this.only) { sql += 'ONLY '; } sql += `${this.source.delimitedFullName} `; if (this.source.loader === 'join') { sql += this.source.joins.map(j => `${j.type} JOIN ${j.target} ON ${j.on.predicate} `).join(''); } sql += `WHERE ${this.predicate}`; if (this.pagination) { sql += ` AND ${this.pagination}`; } if (this.order.length) { sql += ` ORDER BY ${this.order.join(',')}`; } if (this.lock) { sql += ` FOR ${this.lock.strength}`; } if (this.lock && this.lock.lockedRows) { sql += ` ${this.lock.lockedRows}`; } if (this.pageLength) { sql += ` FETCH FIRST ${this.pageLength} ROWS ONLY`; } if (this.offset) { sql += ` OFFSET ${this.offset}`; } if (this.single) { sql += ' LIMIT 1'; } else if (this.limit) { sql += ` LIMIT ${this.limit}`; } return sql; }; module.exports = Select;