UNPKG

feathers-objection

Version:
903 lines (879 loc) 30.1 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.default = init; var _adapterCommons = require("@feathersjs/adapter-commons"); var _errors = _interopRequireDefault(require("@feathersjs/errors")); var _objection = require("objection"); var _utils = _interopRequireDefault(require("./utils")); var _errorHandler = _interopRequireDefault(require("./error-handler")); function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } function _extends() { _extends = Object.assign ? Object.assign.bind() : function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); } const METHODS = { $or: 'orWhere', $and: 'andWhere', $ne: 'whereNot', $in: 'whereIn', $nin: 'whereNotIn', $null: 'whereNull', $not: 'whereNot' }; const OPERATORS = { eq: '$eq', ne: '$ne', gte: '$gte', gt: '$gt', lte: '$lte', lt: '$lt', in: '$in', notIn: '$nin', like: '$like', notLike: '$notLike', ilike: '$ilike', notILike: '$notILike', or: '$or', and: '$and', whereNot: '$not' }; const OPERATORS_MAP = { $lt: '<', $lte: '<=', $gt: '>', $gte: '>=', $like: 'like', $notLike: 'not like', $ilike: 'ilike', $notILike: 'not ilike', $regexp: '~', $notRegexp: '!~', $iRegexp: '~*', $notIRegexp: '!~*', $between: 'between', $notBetween: 'not between', $contains: '@>', $containsKey: '?', $contained: '<@', $any: '?|', $all: '?&' }; const DESERIALIZED_ARRAY_OPERATORS = ['between', 'not between', '?|', '?&']; const NON_COMPARISON_OPERATORS = ['@>', '?', '<@', '?|', '?&']; /** * Class representing an feathers adapter for Objection.js ORM. * @param {object} options * @param {string} [options.id='id'] - database id field * @param {string} [options.idSeparator=','] - id field primary keys separator char * @param {object} options.model - an Objection model * @param {object} options.paginate * @param {object} options.events * @param {string} options.allowedEager - Objection eager loading string. */ class Service extends _adapterCommons.AdapterService { constructor(options) { if (!options.model) { throw new _errors.default.GeneralError('You must provide an Objection Model'); } const whitelist = Object.values(OPERATORS).concat(options.whitelist || []); const id = options.model.idColumn || 'id'; super(_extends({ id }, options, { whitelist })); this.idSeparator = options.idSeparator || ','; this.jsonSchema = options.model.jsonSchema; this.allowedEager = options.allowedEager; this.eagerOptions = options.eagerOptions; this.eagerFilters = options.eagerFilters; this.allowedInsert = options.allowedInsert && _objection.RelationExpression.create(options.allowedInsert); this.insertGraphOptions = options.insertGraphOptions; this.createUseUpsertGraph = options.createUseUpsertGraph; this.allowedUpsert = options.allowedUpsert && _objection.RelationExpression.create(options.allowedUpsert); this.upsertGraphOptions = options.upsertGraphOptions; this.schema = options.schema; } get Model() { return this.options.model; } getModel(params) { return this.options.model; } /** * Create a new query that re-queries all ids that were originally changed * @param id * @param idList * @param addTableName */ getIdsQuery(id, idList, addTableName = true) { const query = {}; if (Array.isArray(this.id)) { let ids = id; if (id && !Array.isArray(id)) { ids = _utils.default.extractIds(id, this.id, this.idSeparator); } this.id.forEach((idKey, index) => { if (!ids) { if (idList) { if (idList[index]) { query[idKey] = idList[index].length === 1 ? idList[index] : { $in: idList[index] }; } } else { query[idKey] = null; } } else if (ids[index]) { query[idKey] = ids[index]; } else { throw new _errors.default.BadRequest('When using composite primary key, id must contain values for all primary keys'); } }); } else { query[addTableName ? `${this.Model.tableName}.${this.id}` : this.id] = idList ? idList.length === 1 ? idList[0] : { $in: idList } : id; } return query; } /** * Maps a feathers query to the Objection/Knex schema builder functions. * @param query - a query object. i.e. { type: 'fish', age: { $lte: 5 } * @param params * @param parentKey * @param methodKey * @param allowRefs */ objectify(query, params, parentKey, methodKey, allowRefs) { if (params.$eager) { delete params.$eager; } if (params.$joinEager) { delete params.$joinEager; } if (params.$joinRelation) { delete params.$joinRelation; } if (params.$leftJoinRelation) { delete params.$leftJoinRelation; } if (params.$modifyEager) { delete params.$modifyEager; } if (params.$mergeEager) { delete params.$mergeEager; } if (params.$noSelect) { delete params.$noSelect; } if (params.$modify) { delete params.$modify; } if (params.$allowRefs) { delete params.$allowRefs; } Object.keys(params || {}).forEach(key => { let value = params[key]; if (key === '$not') { const self = this; if (Array.isArray(value)) { // Array = $and operator value = { $and: value }; } return query.whereNot(function () { // continue with all queries inverted self.objectify(this, value, parentKey, methodKey, allowRefs); }); } if (_utils.default.isPlainObject(value)) { return this.objectify(query, value, key, parentKey, allowRefs); } const column = parentKey && parentKey[0] !== '$' ? parentKey : key; const method = METHODS[methodKey] || METHODS[parentKey] || METHODS[key]; const operator = OPERATORS_MAP[key] || '='; if (method) { if (key === '$or') { const self = this; return query.where(function () { return value.forEach(condition => { this.orWhere(function () { self.objectify(this, condition, null, null, allowRefs); }); }); }); } if (key === '$and') { const self = this; return query.where(function () { return value.forEach(condition => { this.andWhere(function () { self.objectify(this, condition, null, null, allowRefs); }); }); }); } if (key === '$null') { return query[value.toString() === 'true' ? method : 'whereNotNull'](column); } return query[method].call(query, column, value); // eslint-disable-line no-useless-call } const property = this.jsonSchema && this.jsonSchema.properties && (this.jsonSchema.properties[column] || methodKey && this.jsonSchema.properties[methodKey]); let columnType = property && property.type; if (columnType) { if (Array.isArray(columnType)) { columnType = columnType[0]; } if (columnType === 'object' || columnType === 'array') { let refColumn; if (!methodKey && key[0] === '$') { refColumn = (0, _objection.ref)(`${this.Model.tableName}.${column}`); } else { const prop = (methodKey ? column : key).replace(/\(/g, '[').replace(/\)/g, ']'); refColumn = (0, _objection.ref)(`${this.Model.tableName}.${methodKey || column}:${prop}`); } if (operator === '@>') { if (Array.isArray(value)) { value = JSON.stringify(value); } } else if (DESERIALIZED_ARRAY_OPERATORS.includes(operator)) { if (typeof value === 'string' && value[0] === '[' && value[value.length - 1] === ']') { value = JSON.parse(value); } } return query.where(NON_COMPARISON_OPERATORS.includes(operator) ? refColumn : refColumn.castText(), operator, value); } } if (DESERIALIZED_ARRAY_OPERATORS.includes(operator) && typeof value === 'string' && value[0] === '[' && value[value.length - 1] === ']') { value = JSON.parse(value); } if (allowRefs && typeof value === 'string') { const refMatches = value.match(/^ref\((.+)\)$/); if (refMatches) { value = (0, _objection.ref)(refMatches[1]); } } return operator === '=' ? query.where(column, value) : query.where(column, operator, value); }); } mergeRelations(optionRelations, paramRelations) { if (!paramRelations) { return optionRelations; } if (!optionRelations) { return _objection.RelationExpression.create(paramRelations); } return optionRelations.merge(paramRelations); } modifyQuery(query, modify) { let modifiers = null; if (typeof modify === 'string') { if (modify[0] === '[' && modify[modify.length - 1] === ']') { query.modify(...JSON.parse(modify)); } else if (modify[0] === '{' && modify[modify.length - 1] === '}') { modifiers = JSON.parse(modify); } else { query.modify(modify.split(',')); } } else if (Array.isArray(modify)) { query.modify(...modify); } else { modifiers = modify; } if (modifiers) { for (const [modifier, args] of Object.entries(modifiers)) { if (args === true) { query.modify(modifier); } else { query.modify(modifier, ...args); } } } } getGroupByColumns(query) { for (const operation of query._operations) { if (operation.name === 'groupBy') { const args = operation.args; return Array.isArray(args[0]) ? args[0] : args; } } return null; } async _createTransaction(params) { if (!params.transaction && params.atomic) { delete params.atomic; params.transaction = params.transaction || {}; params.transaction.trx = await this.Model.startTransaction(); return params.transaction; } return null; } _commitTransaction(transaction) { return async data => { if (transaction) { await transaction.trx.commit(); } return data; }; } _rollbackTransaction(transaction) { return async err => { if (transaction) { await transaction.trx.rollback(); } throw err; }; } _createQuery(params = {}) { const trx = params.transaction ? params.transaction.trx : null; const schema = params.schema || this.schema; const query = this.Model.query(trx); if (schema) { query.context({ onBuild(builder) { builder.withSchema(schema); } }); } return query; } _selectQuery(q, $select) { if ($select && Array.isArray($select)) { const items = $select.concat(Array.isArray(this.id) ? this.id.map(el => { return `${this.Model.tableName}.${el}`; }) : `${this.Model.tableName}.${this.id}`); for (const [key, item] of Object.entries(items)) { const matches = item.match(/^ref\((.+)\)( as (.+))?$/); if (matches) { items[key] = (0, _objection.ref)(matches[1]).as(matches[3] || matches[1]); } } q.select(...items); } return q; } // Analyse $select and get an object map with fields -> alias _selectAliases($select) { if (!Array.isArray($select)) { return {}; } return $select.reduce((result, item) => { const matches = item.match(/^(?:ref\((\S+)\)|(\S+))(?: as (.+))?$/); if (matches) { const tableField = matches[1] || matches[2]; const field = tableField.startsWith(`${this.Model.tableName}.`) ? tableField.substr(this.Model.tableName.length + 1) : tableField; const alias = matches[3] || field; result[field] = alias; } else { // Can't parse $select ! throw new _errors.default.BadRequest(`${item} is not a valid select statement`); } return result; }, {}); } _selectFields(params, originalData = {}) { return newObject => { if (params.query && params.query.$noSelect) { return originalData; } // Remove not selected fields if (params.query && params.query.$select && !params.query.$select.find(field => field === '*' || field === `${this.Model.tableName}.*`)) { const $fieldsOrAliases = this._selectAliases(params.query.$select); for (const key of Object.keys(newObject)) { if (!$fieldsOrAliases[key]) { delete newObject[key]; } else if ($fieldsOrAliases[key] !== key) { // Aliased field newObject[$fieldsOrAliases[key]] = newObject[key]; delete newObject[key]; } } } return newObject; }; } _checkUpsertId(id, newObject) { const updateId = this.getIdsQuery(id, undefined, false); Object.keys(updateId).forEach(key => { if (!Object.prototype.hasOwnProperty.call(newObject, key)) { newObject[key] = updateId[key]; // id is missing in data, we add it } else if (newObject[key] !== updateId[key]) { throw new _errors.default.BadRequest(`Id '${key}': values mismatch between data '${newObject[key]}' and request '${updateId[key]}'`); } }); } createQuery(params = {}) { const { filters, query } = this.filterQuery(params); const q = this._createQuery(params); const eagerOptions = { ...this.eagerOptions, ...params.eagerOptions }; if (this.allowedEager) { q.allowGraph(this.allowedEager); } if (params.mergeAllowEager) { q.allowGraph(params.mergeAllowEager); } // $select uses a specific find syntax, so it has to come first. this._selectQuery(q, filters.$select); // $eager for Objection eager queries if (query && query.$eager) { q.withGraphFetched(query.$eager, eagerOptions); delete query.$eager; } const joinEager = query && query.$joinEager; if (joinEager) { q.withGraphJoined(query.$joinEager, eagerOptions); delete query.$joinEager; } const joinRelation = query && query.$joinRelation; if (joinRelation) { q.joinRelated(query.$joinRelation); delete query.$joinRelation; } const leftJoinRelation = query && query.$leftJoinRelation; if (leftJoinRelation) { q.leftJoinRelated(query.$leftJoinRelation); delete query.$leftJoinRelation; } if (query && query.$mergeEager) { q[joinEager ? 'withGraphJoined' : 'withGraphFetched'](query.$mergeEager, eagerOptions); delete query.$mergeEager; } if (query && query.$modify) { this.modifyQuery(q, query.$modify); delete query.$modify; } if (joinRelation || leftJoinRelation) { const groupByColumns = this.getGroupByColumns(q); if (!groupByColumns) { q.distinct(`${this.Model.tableName}.*`); } } // apply eager filters if specified if (this.eagerFilters) { const eagerFilters = Array.isArray(this.eagerFilters) ? this.eagerFilters : [this.eagerFilters]; for (const eagerFilter of eagerFilters) { q.modifyGraph(eagerFilter.expression, eagerFilter.filter); } } if (query && query.$modifyEager) { for (const eagerFilterExpression of Object.keys(query.$modifyEager)) { const eagerFilterQuery = query.$modifyEager[eagerFilterExpression]; q.modifyGraph(eagerFilterExpression, builder => { this.objectify(builder, eagerFilterQuery, null, null, query.$allowRefs); }); } delete query.$modifyEager; } // build up the knex query out of the query params this.objectify(q, query, null, null, query.$allowRefs); if (filters.$sort) { Object.keys(filters.$sort).forEach(item => { const matches = item.match(/^ref\((.+)\)$/); const key = matches ? (0, _objection.ref)(matches[1]) : item; q.orderBy(key, filters.$sort[item] === 1 ? 'asc' : 'desc'); }); } return q; } /** * `find` service function for Objection. * @param params */ _find(params = {}) { const find = (params, count, filters, query) => { const q = params.objection || this.createQuery(params); const groupByColumns = this.getGroupByColumns(q); // Handle $limit if (filters.$limit) { q.limit(filters.$limit); } // Handle $skip if (filters.$skip) { q.offset(filters.$skip); } let executeQuery = total => { return q.then(data => { return { total, limit: filters.$limit, skip: filters.$skip || 0, data }; }); }; if (filters.$limit === 0) { executeQuery = total => { return Promise.resolve({ total, limit: filters.$limit, skip: filters.$skip || 0, data: [] }); }; } if (count) { const countColumns = groupByColumns || (Array.isArray(this.id) ? this.id.map(idKey => `${this.Model.tableName}.${idKey}`) : [`${this.Model.tableName}.${this.id}`]); const countQuery = this._createQuery(params); if (query.$joinRelation || query.$leftJoinRelation) { if (query.$joinRelation) { countQuery.joinRelated(query.$joinRelation).countDistinct({ total: countColumns }); } if (query.$leftJoinRelation) { countQuery.leftJoinRelated(query.$leftJoinRelation).countDistinct({ total: countColumns }); } } else if (query.$joinEager) { countQuery.joinRelated(query.$joinEager).countDistinct({ total: countColumns }); } else if (countColumns.length > 1) { countQuery.countDistinct({ total: countColumns }); } else { countQuery.count({ total: countColumns }); } if (query && query.$modify && params.modifierFiltersResults !== false) { this.modifyQuery(countQuery, query.$modify); } this.objectify(countQuery, query, null, null, query.$allowRefs); return countQuery.then(count => count && count.length ? parseInt(count[0].total, 10) : 0).then(executeQuery).catch(_errorHandler.default); } return executeQuery().catch(_errorHandler.default); }; const { filters, query, paginate } = this.filterQuery(params); const result = find(params, Boolean(paginate && paginate.default), filters, query); if (!paginate || !paginate.default) { return result.then(page => page.data || page); } return result; } _get(id, params = {}) { // merge user query with the 'id' to get const findQuery = _extends({}, { $and: [] }, params.query); findQuery.$and.push(this.getIdsQuery(id)); // BUG will fail with composite primary key because table name will be missing return this._find(_extends({}, params, { query: findQuery })).then(page => { const data = page.data || page; if (data.length !== 1) { throw new _errors.default.NotFound(`No record found for id '${id}'`); } return data[0]; }); } _getCreatedRecords(insertResults, inputData, params) { if (params.query && params.query.$noSelect) { return inputData; } if (!Array.isArray(insertResults)) { insertResults = [insertResults]; } const findQuery = _extends({ $and: [] }, params.query); const idsQueries = []; if (Array.isArray(this.id)) { for (const insertResult of insertResults) { const ids = []; for (const idKey of this.id) { if (idKey in insertResult) { ids.push(insertResult[idKey]); } else { return inputData; } } idsQueries.push(this.getIdsQuery(ids)); } } else { const ids = []; for (const insertResult of insertResults) { if (this.id in insertResult) { ids.push(insertResult[this.id]); } else { return inputData; } } idsQueries.push(this.getIdsQuery(null, ids)); } if (idsQueries.length > 1) { findQuery.$and.push({ $or: idsQueries }); } else { findQuery.$and = findQuery.$and.concat(idsQueries); } return this._find(_extends({}, params, { query: findQuery })).then(page => { const records = page.data || page; if (Array.isArray(inputData)) { return records; } return records[0]; }); } /** * @param data * @param params * @returns {Promise<Object|Object[]>} * @private */ _batchInsert(data, params) { const { dialect } = this.Model.knex().client; // batch insert only works with Postgresql and SQL Server if (dialect === 'postgresql' || dialect === 'mssql') { return this._createQuery(params).insert(data).returning(this.id); } if (!Array.isArray(data)) { return this._createQuery(params).insert(data); } const promises = data.map(dataItem => { return this._createQuery(params).insert(dataItem); }); return Promise.all(promises); } /** * `create` service function for Objection. * @param {object} data * @param {object} params */ async _create(data, params = {}) { const transaction = await this._createTransaction(params); const q = this._createQuery(params); let promise = q; const allowedUpsert = this.mergeRelations(this.allowedUpsert, params.mergeAllowUpsert); const allowedInsert = this.mergeRelations(this.allowedInsert, params.mergeAllowInsert); const upsertGraphOptions = { ...this.upsertGraphOptions, ...params.mergeUpsertGraphOptions }; const insertGraphOptions = { ...this.insertGraphOptions, ...params.mergeInsertGraphOptions }; if (this.createUseUpsertGraph) { if (allowedUpsert) { q.allowGraph(allowedUpsert); } q.upsertGraph(data, upsertGraphOptions); } else if (allowedInsert) { q.allowGraph(allowedInsert); q.insertGraph(data, insertGraphOptions); } else { promise = this._batchInsert(data, params); } return promise.then(insertResults => this._getCreatedRecords(insertResults, data, params)).then(this._commitTransaction(transaction), this._rollbackTransaction(transaction)).catch(_errorHandler.default); } /** * `update` service function for Objection. * @param id * @param data * @param params */ _update(id, data, params = {}) { // NOTE : First fetch the item to update to account for user query return this._get(id, params).then(() => { // NOTE: Next, fetch table metadata so // that we can fill any existing keys that the // client isn't updating with null; return this.Model.fetchTableMetadata().then(async meta => { let newObject = _extends({}, data); let transaction = null; const allowedUpsert = this.mergeRelations(this.allowedUpsert, params.mergeAllowUpsert); if (allowedUpsert) { // Ensure the object we fetched is the one we update this._checkUpsertId(id, newObject); // Create transaction if needed transaction = await this._createTransaction(params); } for (const key of meta.columns) { if (newObject[key] === undefined) { newObject[key] = null; } } if (allowedUpsert) { const upsertGraphOptions = { ...this.upsertGraphOptions, ...params.mergeUpsertGraphOptions }; return this._createQuery(params).allowGraph(allowedUpsert).upsertGraphAndFetch(newObject, upsertGraphOptions).then(this._commitTransaction(transaction), this._rollbackTransaction(transaction)); } // NOTE (EK): Delete id field so we don't update it if (Array.isArray(this.id)) { for (const idKey of this.id) { delete newObject[idKey]; } } else { delete newObject[this.id]; } return this._createQuery(params).where(this.getIdsQuery(id)).update(newObject).then(() => { // BUG if nothing updated, throw a NotFound // NOTE (EK): Restore the id field so we can return it to the client if (Array.isArray(this.id)) { newObject = _extends({}, newObject, this.getIdsQuery(id)); } else { newObject[this.id] = id; } return newObject; }); }).then(this._selectFields(params, data)); }).catch(_errorHandler.default); } /** * `patch` service function for Objection. * @param id * @param data * @param params */ _patch(id, data, params = {}) { let { filters, query } = this.filterQuery(params); const allowedUpsert = this.mergeRelations(this.allowedUpsert, params.mergeAllowUpsert); const upsertGraphOptions = { ...this.upsertGraphOptions, ...params.mergeUpsertGraphOptions }; if (allowedUpsert && id !== null) { const dataCopy = _extends({}, data); this._checkUpsertId(id, dataCopy); // Get object first to ensure it satisfy user query return this._get(id, params).then(async () => { // Create transaction if needed const transaction = await this._createTransaction(params); return this._createQuery(params).allowGraph(allowedUpsert).upsertGraphAndFetch(dataCopy, upsertGraphOptions).then(this._selectFields(params, data)).then(this._commitTransaction(transaction), this._rollbackTransaction(transaction)); }); } const dataCopy = _extends({}, data); const mapIds = page => Array.isArray(this.id) ? this.id.map(idKey => [...new Set((page.data || page).map(current => current[idKey]))]) : (page.data || page).map(current => current[this.id]); // By default we will just query for the one id. For multi patch // we create a list of the ids of all items that will be changed // to re-query them after the update const ids = id === null ? this._find(params).then(mapIds) : Promise.resolve([id]); if (id !== null) { if (Array.isArray(this.id)) { query = _extends({}, query, this.getIdsQuery(id)); } else { query[this.id] = id; } } const q = this._createQuery(params); this.objectify(q, query, null, null, query.$allowRefs); if (Array.isArray(this.id)) { for (const idKey of this.id) { delete dataCopy[idKey]; } } else { delete dataCopy[this.id]; } return ids.then(idList => { // Create a new query that re-queries all ids that // were originally changed const selectParam = filters.$select ? { $select: filters.$select } : undefined; const findParams = _extends({}, params, { query: _extends({}, params.query, this.getIdsQuery(id, idList), selectParam) }); // Update find query if needed with patched values const updateKeys = obj => { for (const key of Object.keys(obj)) { if (key in dataCopy) { obj[key] = dataCopy[key]; } else { if (Array.isArray(obj[key])) { obj[key].forEach(obj => updateKeys(obj)); } } } }; updateKeys(findParams.query); return q.patch(dataCopy).then(() => { return params.query && params.query.$noSelect ? dataCopy : this._find(findParams).then(page => { const items = page.data || page; if (id !== null) { if (items.length === 1) { return items[0]; } else { throw new _errors.default.NotFound(`No record found for id '${id}'`); } } else if (!items.length) { throw new _errors.default.NotFound(`No record found for id '${id}'`); } return items; }); }); }).catch(_errorHandler.default); } /** * `remove` service function for Objection. * @param id * @param params */ _remove(id, params = {}) { params.query = _extends({}, params.query); // NOTE (EK): First fetch the record so that we can return // it when we delete it. if (id !== null) { if (Array.isArray(this.id)) { params.query = _extends({}, params.query, this.getIdsQuery(id)); } else { params.query[this.id] = id; } } const { query: queryParams } = this.filterQuery(params); const query = this._createQuery(params); this.objectify(query, queryParams, null, null, query.$allowRefs); if (params.query && params.query.$noSelect) { return query.delete().then(() => { return {}; }).catch(_errorHandler.default); } else { return this._find(params).then(page => { const items = page.data || page; return query.delete().then(() => { if (id !== null) { if (items.length === 1) { return items[0]; } else { throw new _errors.default.NotFound(`No record found for id '${id}'`); } } else if (!items.length) { throw new _errors.default.NotFound(`No record found for id '${id}'`); } return items; }); }).catch(_errorHandler.default); } } } function init(options) { return new Service(options); } init.Service = Service; init.ERROR = _errorHandler.default.ERROR; module.exports = exports.default;