diff --git a/data-mapping-extensions.js b/data-mapping-extensions.js index fc80f8c..ae0350d 100644 --- a/data-mapping-extensions.js +++ b/data-mapping-extensions.js @@ -5,6 +5,7 @@ var {QueryEntity} = require('@themost/query'); var {QueryField} = require('@themost/query'); var Q = require('q'); var {hasOwnProperty} = require('./has-own-property'); +var {isObjectDeep} = require('./is-object'); class DataMappingExtender { constructor(mapping) { @@ -342,9 +343,20 @@ class DataMappingExtender { if (_.isNil(childField)) { return reject('The specified field cannot be found on child model'); } + var childFieldType = thisQueryable.model.context.model(childField.type); var values = _.intersection(_.map(_.filter(arr, function(x) { return hasOwnProperty(x, keyField); - }), function (x) { return x[keyField];})); + }), function (x) { return x[keyField];})).map(function(x) { + if (isObjectDeep(x)) { + if (childFieldType) { + return x[childFieldType.primaryKey]; + } + throw new Error('The child item is an object but its type cannot determined.'); + } + return x; + }).filter(function(x) { + return x != null; + }); if (values.length===0) { return resolve(); } @@ -421,9 +433,20 @@ class DataMappingExtender { return reject('The specified field cannot be found on parent model'); } var keyField = parentField.property || parentField.name; + var parentFieldType = thisQueryable.model.context.model(parentField.type); var values = _.intersection(_.map(_.filter(arr, function(x) { return hasOwnProperty(x, keyField); - }), function (x) { return x[keyField];})); + }), function (x) { return x[keyField];})).map(function(x) { + if (isObjectDeep(x)) { + if (parentFieldType) { + return x[parentFieldType.primaryKey]; + } + throw new Error('The parent item is an object but its type cannot determined.'); + } + return x; + }).filter(function(x) { + return x != null; + }); if (values.length===0) { return resolve(); } @@ -528,7 +551,7 @@ class DataMappingExtender { }); return resolve(); }).catch(function(err) { - return resolve(err); + return reject(err); }); }); }); @@ -872,11 +895,22 @@ class DataMappingOptimizedExtender extends DataMappingExtender { return reject('The specified field cannot be found on parent model'); } var keyField = parentField.property || parentField.name; + var parentFieldType = thisQueryable.model.context.model(parentField.type); var values = _.intersection(_.map(_.filter(arr, function(x) { return hasOwnProperty(x, keyField); }), function (x) { return x[keyField]; - })); + })).map(function(x) { + if (isObjectDeep(x)) { + if (parentFieldType) { + return x[parentFieldType.primaryKey]; + } + throw new Error('The parent item is an object but its type cannot determined.'); + } + return x; + }).filter(function(x) { + return x != null; + });; if (values.length===0) { return resolve(); } @@ -977,7 +1011,7 @@ class DataMappingOptimizedExtender extends DataMappingExtender { }); return resolve(); }).catch(function(err) { - return resolve(err); + return reject(err); }); }); }); diff --git a/data-model.js b/data-model.js index 1913e95..e0d6dbb 100644 --- a/data-model.js +++ b/data-model.js @@ -2,7 +2,7 @@ // noinspection ES6ConvertVarToLetConst var _ = require('lodash'); -var {cloneDeep} = require('lodash'); +var cloneDeep = require('lodash/cloneDeep'); var {sprintf} = require('sprintf-js'); var Symbol = require('symbol'); var path = require('path'); @@ -738,25 +738,104 @@ DataModel.prototype.asQueryable = function() { /** * @private * @this DataModel - * @param {*} params + * @param {*} filterParams * @param {Function} callback * @returns {*} */ -function filterInternal(params, callback) { +function filterInternal(filterParams, callback) { var self = this; var parser = OpenDataParser.create(), $joinExpressions = [], view; - if (typeof params !== 'undefined' && params !== null && typeof params.$select === 'string') { - //split select - var arr = params.$select.split(','); - if (arr.length===1) { - //try to get data view - view = self.dataviews(arr[0]); + var params = cloneDeep(filterParams); + if (params && typeof params.$select === 'string') { + if (/^(\w+)$/.test(params.$select)) { + // try to get data view + view = self.dataviews(params.$select); + if (view) { + const viewParams =Object.assign({}, params); + // define view attributes + var $select = view.fields.filter(function(x) { + const member = x.name.split('/'); + if (member.length === 1) { + var attribute = self.getAttribute(member[0]); + if (attribute) { + return typeof attribute.many === 'boolean' ? !attribute.many : true; + } + } + // todo: check for nested attributes with many-to-many association + return true; + }).map(function(x) { + if (x.property) { + return sprintf('%s as %s', x.name, x.property); + } + return x.name; + }).join(','); + Object.assign(viewParams, { + $select + }); + // get auto-expand attributes + var $expand = view.fields.filter(function(x) { + const member = x.name.split('/'); + if (member.length === 1) { + var attribute = self.getAttribute(member[0]); + if (attribute) { + return typeof attribute.many === 'boolean' ? attribute.many && attribute.expandable : false; + } + } + // todo: check for nested attributes with many-to-many association + return false; + }).map(function(x) { + return x.name; + }).join(','); + if ($expand.length) { + Object.assign(viewParams, { + $expand + }); + } + if (view.levels) { + Object.assign(viewParams, { $levels: view.levels }); + } + // assign view + Object.assign(viewParams, { $view: view.name }); + return self.filter(viewParams, function(err, q) { + if (err) { + return callback(err); + } + // assign view to query + Object.assign(q, { $view: view }); + // return query + return callback(null, q); + }); + } } } + // important: if $view parameter is defined then try to get view + // in order to validate member expressions + if (params && typeof params.$view === 'string') { + view = self.getDataView(params.$view); + } parser.resolveMember = function(member, cb) { if (view) { - var field = view.fields.find(function(x) { return x.property === member }); - if (field) { member = field.name; } + var field = view.fields.find(function(x) { + if (typeof x.property === 'string') { + return x.name === member || x.property === member; + } + return x.name === member; + }); + if (field) { + member = field.name; + } else { + var memberParts = member.split('/'); + field = view.fields.find(function(x) { + if (typeof x.property === 'string') { + return x.property === memberParts[0]; + } + return x.name === memberParts[0]; + }); + if (field == null) { + // throw exception for invalid usage of field + throw new DataError('E_INVALID_ATTR', 'The specified attribute is not valid at the context of a pre-defined object view.', null, self.name, member); + } + } } var attr = self.field(member); if (attr) @@ -779,8 +858,13 @@ function filterInternal(params, callback) { var arrExpr = []; if (_.isArray(expr)) arrExpr.push.apply(arrExpr, expr); - else - arrExpr.push(expr); + else { + if (expr.$expand) { + arrExpr.push.apply(arrExpr, expr.$expand); + } else { + arrExpr.push(expr); + } + } arrExpr.forEach(function(y) { var joinExpr = $joinExpressions.find(function(x) { if (x.$entity && x.$entity.$as) { @@ -791,6 +875,9 @@ function filterInternal(params, callback) { if (_.isNil(joinExpr)) $joinExpressions.push(y); }); + if (expr.$select) { + return cb(null, expr.$select); + } } } catch (err) { @@ -826,82 +913,107 @@ function filterInternal(params, callback) { } try { - parser.parse(filter, function(err, query) { + async.series([ + function(cb) { + return parser.parse(filter, cb); + }, + function (cb) { + // use parseSelectSequence to split tokens + var select = params.$select; + if (select == null) { + return cb(null, []); + } + return parser.parseSelectSequence(select, cb); + }, + function (cb) { + // use parseSelectSequence to split tokens + var orderBy = params.$orderby || params.$orderBy || params.$order; + if (orderBy == null) { + return cb(null, []); + } + return parser.parseOrderBySequence(orderBy, cb); + }, + function (cb) { + // use parseSelectSequence to split tokens + var groupBy = params.$groupby || params.$groupBy || params.$group; + if (groupBy == null) { + return cb(null, []); + } + return parser.parseGroupBySequence(groupBy, cb); + } + ], function(err, results) { if (err) { callback(err); - } - else { - //create a DataQueryable instance - var q = new DataQueryable(self); - q.query.$where = query; - if ($joinExpressions.length>0) - q.query.$expand = $joinExpressions; - //prepare - q.query.prepare(); - - if (typeof params === 'object') { - //apply query parameters - var select = params.$select, - skip = params.$skip || 0, - orderBy = params.$orderby || params.$order, - groupBy = params.$groupby || params.$group, - expand = params.$expand, - levels = parseInt(params.$levels), - top = params.$top || params.$take; - //select fields - if (typeof select === 'string') { - q.select.apply(q, select.split(',').map(function(x) { - return x.replace(/^\s+|\s+$/g, ''); - })); - } - //apply group by fields - if (typeof groupBy === 'string') { - q.groupBy.apply(q, groupBy.split(',').map(function(x) { - return x.replace(/^\s+|\s+$/g, ''); - })); - } - if ((typeof levels === 'number') && !isNaN(levels)) { - //set expand levels - q.levels(levels); + } else { + try { + var [where, select, orderBy, groupBy] = results; + //create a DataQueryable instance + var q = new DataQueryable(self); + if (where) { + q.query.$where = where; } - //set $skip - q.skip(skip); - if (top) - q.query.take(top); - //set caching - if (params.$cache && self.caching === 'conditional') { - q.cache(true); + if ($joinExpressions.length>0) { + q.query.$expand = $joinExpressions; } - //set $orderby - if (orderBy) { - orderBy.split(',').map(function(x) { - return x.replace(/^\s+|\s+$/g, ''); - }).forEach(function(x) { - if (/\s+desc$/i.test(x)) { - q.orderByDescending(x.replace(/\s+desc$/i, '')); - } - else if (/\s+asc/i.test(x)) { - q.orderBy(x.replace(/\s+asc/i, '')); - } - else { - q.orderBy(x); + // use prepare statement to allow further processing + q.query.prepare(); + if (typeof params === 'object') { + //apply query parameters + var skip = params.$skip || 0; + var expand = params.$expand; + var levels = parseInt(params.$levels); + var top = params.$top || params.$take; + //select fields + var { viewAdapter: collection } = self; + if (select.length>0) { + q.query.$select = { + [collection]: select.map(function(selectArg) { + return selectArg.exprOf(); + }) + }; + } else { + q.select(); + } + //apply group by fields + if (groupBy.length>0) { + q.query.$group = groupBy.map(function(groupByArg) { + return groupByArg.exprOf(); + }); + } + if ((typeof levels === 'number') && !isNaN(levels)) { + // set expand levels + q.levels(levels); + } + //set $skip + q.skip(skip); + if (top) { + q.query.take(top); + } + //set caching + if (params.$cache && self.caching === 'conditional') { + q.cache(true); + } + //set $orderby + if (orderBy.length) { + q.query.$order = orderBy.map(function(orderByArg) { + return orderByArg.exprOf(); + }); + } + if (expand) { + var matches = resolver.testExpandExpression(expand); + if (matches && matches.length>0) { + q.expand.apply(q, matches); } - }); - } - if (expand) { - var matches = resolver.testExpandExpression(expand); - if (matches && matches.length>0) { - q.expand.apply(q, matches); } + + return callback(null, q); + } else { + // and finally return an instance of DataQueryable + callback(null, q); } - //return - callback(null, q); - } - else { - //and finally return DataQueryable instance - callback(null, q); + } catch (err) { + return callback(err); } - } }); } @@ -915,14 +1027,6 @@ function filterInternal(params, callback) { * @param {String|{$filter:string=, $skip:number=, $levels:number=, $top:number=, $take:number=, $order:string=, $inlinecount:string=, $expand:string=,$select:string=, $orderby:string=, $group:string=, $groupby:string=}} params - A string that represents an open data filter or an object with open data parameters * @param {Function=} callback - A callback function where the first argument will contain the Error object if an error occurred, or null otherwise. The second argument will contain an instance of DataQueryable class. * @returns Promise|* - * @example - context.model('Order').filter(context.params, function(err,q) { - if (err) { return callback(err); } - q.take(10, function(err, result) { - if (err) { return callback(err); } - callback(null, result); - }); - }); */ DataModel.prototype.filter = function(params, callback) { if (typeof callback === 'function') { diff --git a/package-lock.json b/package-lock.json index c56d9de..f8fd14a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@themost/data", - "version": "2.6.78", + "version": "2.6.79", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@themost/data", - "version": "2.6.78", + "version": "2.6.79", "license": "BSD-3-Clause", "dependencies": { "@themost/events": "^1.3.0", diff --git a/package.json b/package.json index c6cdfc7..d3d1f36 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@themost/data", - "version": "2.6.78", + "version": "2.6.79", "description": "MOST Web Framework Codename Blueshift - Data module", "main": "index.js", "types": "index.d.ts", diff --git a/spec/DataAttributeResolver.spec.ts b/spec/DataAttributeResolver.spec.ts index 0e27705..6d07db5 100644 --- a/spec/DataAttributeResolver.spec.ts +++ b/spec/DataAttributeResolver.spec.ts @@ -112,21 +112,5 @@ describe('DataAttributeResolver', () => { expect(items.value.length).toBeGreaterThan(0); }); }); - - - it('should get nested item', async () => { - await TestUtils.executeInTransaction(context, async () => { - const product = await context.model('Product').asQueryable().silent().getItem(); - product.productImage = { - url: '/images/products/abc.png' - } - await context.model('Product').silent().save(product); - Object.assign(context, { - user: null - }); - let item = await context.model('Product').where('id').equal(product.id).getItem(); - expect(item.productImage).toBeTruthy(); - }); - }); }); diff --git a/spec/DataModelFilterParser.spec.ts b/spec/DataModelFilterParser.spec.ts index 8f73492..c348416 100644 --- a/spec/DataModelFilterParser.spec.ts +++ b/spec/DataModelFilterParser.spec.ts @@ -113,4 +113,43 @@ describe('DataModelFilterParser', () => { }); }); + + it('should parse select filter with expression', async () => { + await TestUtils.executeInTransaction(context, async () => { + delete context.user; + const Orders = context.model('Order'); + let q = await Orders.filterAsync({ + $select: 'orderedItem/name as product,round(orderedItem/price,2) as price, max(orderDate) as lastOrderDate', + $filter: 'orderedItem/category eq \'Laptops\'', + $orderBy: 'orderedItem/price desc', + $groupBy: 'orderedItem/name,orderedItem/price' + }) + let items: {product: string, price: number, lastOrderDate: Date}[] = await q.take(25).getItems(); + expect(items.length).toBeFalsy(); + context.user = { + name: 'aaron.matthews@example.com' + } + q = await Orders.filterAsync({ + $select: 'orderedItem/name as product,round(orderedItem/price,2) as price, max(orderDate) as lastOrderDate', + //$filter: 'orderedItem/category eq \'Laptops\'', + $orderBy: 'orderedItem/price desc', + $groupBy: 'orderedItem/name,orderedItem/price' + }) + items = await q.getItems(); + expect(items.length).toBeTruthy(); + // get orders + const orders = await context.model('Order').select( + 'orderedItem/name as product', + 'orderDate' + ).getItems(); + for (const item of items) { + const order = orders.sort((a, b) => { + return a.orderDate < b.orderDate ? 1 : -1; + }).find(o => o.product === item.product); + expect(order).toBeTruthy(); + expect(order.orderDate).toEqual(item.lastOrderDate); + } + }); + }); + }); diff --git a/spec/DataView.spec.ts b/spec/DataView.spec.ts new file mode 100644 index 0000000..7c59dad --- /dev/null +++ b/spec/DataView.spec.ts @@ -0,0 +1,188 @@ +import { resolve } from 'path'; +import { DataContext } from '../index'; +import { TestApplication } from './TestApplication'; +import { TestUtils } from './adapter/TestUtils'; +import cloneDeep from 'lodash/cloneDeep'; +const executeInTransaction = TestUtils.executeInTransaction; + +interface DataContextWithUser extends DataContext { + user: any +} + +const contributor = { + "enabled": 1, + "name": "michael.barret@example.com", + "description": "Michael Barret", + "groups": [ + { + "name": "Contributors" + } + ] + }; + +function getNewContributor() { + return { + "enabled": 1, + "name": "michael.barret@example.com", + "description": "Michael Barret", + "groups": [ + { + "name": "Contributors" + } + ] + } +} + +function getNewAgent() { + return { + "enabled": 1, + "name": "jenna.borrows@example.com", + "description": "Jenna Borrows", + "groups": [ + { + "name": "Agents" + } + ] + } +} + +describe('DataView', () => { + let app: TestApplication; + let context: DataContextWithUser; + beforeAll(async () => { + app = new TestApplication(resolve(__dirname, 'test2')); + context = app.createContext() as DataContextWithUser; + }); + afterAll(async () => { + await context.finalizeAsync(); + await app.finalize(); + }); + afterEach(() => { + delete context.user; + }); + + it('should validate view pre-defined privileges', async () => { + await executeInTransaction(context, async () => { + const newUser = getNewContributor(); + await context.model('User').silent().save(newUser); + Object.assign(context, { + user: { + name: newUser.name + } + }); + const Orders = context.model('Order'); + const q = await Orders.filterAsync({ + $select: 'Delivered', + $orderby: 'orderDate desc', + }); + const items = await q.getList(); + expect(items).toBeTruthy(); + expect(Array.isArray(items.value)).toBeTruthy(); + expect(items.value.length).toBeGreaterThan(0); + for (const item of items.value) { + expect(item.orderStatus.id).toEqual(1); + } + }); + }); + + it('should try to use an attribute which is not included in view', async () => { + await executeInTransaction(context, async () => { + const newUser = getNewContributor(); + await context.model('User').silent().save(newUser); + Object.assign(context, { + user: { + name: newUser.name + } + }); + const Orders = context.model('Order'); + await expect(Orders.filterAsync({ + $select: 'Delivered', + $filter: 'customer/jobTitle eq \'Civil Engineer\'' + })).rejects.toThrow('The specified attribute is not valid at the context of a pre-defined object view.'); + }); + }); + + it('should try to use an attribute having alias', async () => { + await executeInTransaction(context, async () => { + const newUser = getNewContributor(); + await context.model('User').silent().save(newUser); + Object.assign(context, { + user: { + name: newUser.name + } + }); + const Orders = context.model('Order'); + const q = await Orders.filterAsync({ + $select: 'Delivered', + $filter: 'customerFamilyName eq \'Chapman\'' + }); + const items = await q.getList(); + expect(items.value.length).toBeGreaterThan(0); + }); + }); + + it('should try to expand a view attribute using a specific child view', async () => { + await executeInTransaction(context, async () => { + await context.model('Group').silent().save({ + "name": "Agents", + "alternateName": "agents", + "description": "Site Agents" + }); + const newUser = getNewAgent(); + await context.model('User').silent().save(newUser); + Object.assign(context, { + user: { + name: newUser.name + } + }); + const Orders = context.model('Order'); + let q = await Orders.filterAsync({ + $select: 'Processing', + $expand: 'customer($select=summary)' + }); + let items = await q.getList(); + expect(items.value.length).toBeGreaterThan(0); + for (const item of items.value) { + expect(item.customer).toBeTruthy(); + expect(item.customer.familyName).toBeTruthy(); + } + q = await Orders.filterAsync({ + $select: 'Processing', + $expand: 'customer' + }); + items = await q.getList(); + expect(items.value.length).toBeGreaterThan(0); + for (const item of items.value) { + expect(item.customer).toBeFalsy(); + } + }); + }); + + it('should try to expand a view attribute using a an attribute which is not included in child view', async () => { + await executeInTransaction(context, async () => { + await context.model('Group').silent().save({ + "name": "Agents", + "alternateName": "agents", + "description": "Site Agents" + }); + const newUser = getNewAgent(); + await context.model('User').silent().save(newUser); + Object.assign(context, { + user: { + name: newUser.name + } + }); + const Orders = context.model('Order'); + let q = await Orders.filterAsync({ + $select: 'Processing', + $expand: 'customer($select=familyName,jobTitle)' + }); + let items = await q.getList(); + expect(items.value.length).toBeGreaterThan(0); + for (const item of items.value) { + expect(item.customer).toBeFalsy(); + } + }); + }); + +}); \ No newline at end of file diff --git a/spec/TestTemplates.spec.ts b/spec/TestTemplates.spec.ts new file mode 100644 index 0000000..cf02913 --- /dev/null +++ b/spec/TestTemplates.spec.ts @@ -0,0 +1,31 @@ +import { resolve } from 'path'; +import { DataContext } from '../index'; +import { TestApplication } from './TestApplication'; +import { TestUtils } from './adapter/TestUtils'; +const executeInTransaction = TestUtils.executeInTransaction; + +interface DataContextWithUser extends DataContext { + user: any +} + +describe('TestTemplate', () => { + let app: TestApplication; + let context: DataContextWithUser; + beforeAll(async () => { + app = new TestApplication(resolve(__dirname, 'test2')); + context = app.createContext() as DataContextWithUser; + }); + afterAll(async () => { + await context.finalizeAsync(); + await app.finalize(); + }); + afterEach(() => { + delete context.user; + }); + + it('should test something', async () => { + await executeInTransaction(context, async () => { + + }); + }); +}); \ No newline at end of file diff --git a/spec/test2/config/models/Group.json b/spec/test2/config/models/Group.json index fc6d98c..d8b50ad 100644 --- a/spec/test2/config/models/Group.json +++ b/spec/test2/config/models/Group.json @@ -52,6 +52,11 @@ "name": "Contributors", "alternateName": "contributors", "description": "Site Contributors" + }, + { + "name": "Agents", + "alternateName": "agents", + "description": "Site Agents" } ] } diff --git a/spec/test2/config/models/Order.json b/spec/test2/config/models/Order.json index 5c2bfa7..caa7710 100644 --- a/spec/test2/config/models/Order.json +++ b/spec/test2/config/models/Order.json @@ -178,6 +178,9 @@ { "name": "orderDate" }, + { + "name": "orderStatus" + }, { "name": "orderedItem" }, @@ -198,6 +201,42 @@ } ] }, + { + "name": "Processing", + "title": "Processing Orders", + "filter": "orderStatus eq 6", + "order": "dateCreated desc", + "fields": [ + { + "name": "id" + }, + { + "name": "orderDate" + }, + { + "name": "orderStatus" + }, + { + "name": "orderedItem" + }, + { + "name": "customer", + "type": "Person" + } + ], + "privileges": [ + { + "mask": 1, + "account": "Contributors", + "type": "global" + }, + { + "mask": 1, + "account": "Agents", + "type": "global" + } + ] + }, { "name": "Latest", "title": "Latest Orders", diff --git a/spec/test2/config/models/Person.json b/spec/test2/config/models/Person.json index 0fea223..ccbc29a 100644 --- a/spec/test2/config/models/Person.json +++ b/spec/test2/config/models/Person.json @@ -179,6 +179,44 @@ } } ], + "views": [ + { + "name": "Summary", + "fields": [ + { + "name": "id" + }, + { + "name": "familyName" + }, + { + "name": "givenName" + } + ], + "privileges": [ + { + "mask": 15, + "type": "global" + }, + { + "mask": 15, + "type": "global", + "account": "Administrators" + }, + { + "mask": 1, + "type": "global", + "account": "Contributors" + }, + { + "mask": 1, + "type": "global", + "account": "Agents" + } + ] + + } + ], "privileges": [ { "mask": 15,