From 16dc40b1bd78b587bbe580ec2ba96b4ca8104d3a Mon Sep 17 00:00:00 2001 From: Kyriakos Barbounakis Date: Mon, 30 Sep 2024 10:03:06 +0300 Subject: [PATCH] Validate attribute on filter or select (#163) * validate attribute on filter or select * validate attribute name * format error inner message --- data-attribute-resolver.js | 5 ++- data-errors.d.ts | 5 +++ data-errors.js | 31 +++++++++++++ data-filter-resolver.js | 14 +++--- data-queryable.js | 27 +++++++++--- index.d.ts | 1 + index.js | 5 ++- most-data-resources.en-us.json | 3 +- package-lock.json | 29 +++++++------ package.json | 4 +- spec/DataQueryable.spec.ts | 79 ++++++++++++++++++++++++++++++++++ 11 files changed, 169 insertions(+), 34 deletions(-) create mode 100644 data-errors.d.ts create mode 100644 data-errors.js diff --git a/data-attribute-resolver.js b/data-attribute-resolver.js index 40df689..7720e98 100644 --- a/data-attribute-resolver.js +++ b/data-attribute-resolver.js @@ -5,6 +5,7 @@ var {DataError} = require('@themost/common'); var Symbol = require('symbol'); var {hasOwnProperty} = require('./has-own-property'); var aliasProperty = Symbol('alias'); +var {UnknownAttributeError} = require('./data-errors'); /** * @class * @constructor @@ -172,8 +173,8 @@ DataAttributeResolver.prototype.resolveNestedAttributeJoin = function(memberExpr //if the specified member contains '/' e.g. user/name then prepare join var arrMember = memberExprString.split('/'); var attrMember = self.field(arrMember[0]); - if (_.isNil(attrMember)) { - throw new Error(sprintf('The target model does not have an attribute named as %s',arrMember[0])); + if (attrMember == null) { + throw new UnknownAttributeError(self.name, arrMember[0]); } //search for field mapping var mapping = self.inferMapping(arrMember[0]); diff --git a/data-errors.d.ts b/data-errors.d.ts new file mode 100644 index 0000000..887193f --- /dev/null +++ b/data-errors.d.ts @@ -0,0 +1,5 @@ +import { DataError } from '@themost/common'; + +export declare class UnknownAttributeError extends DataError { + constructor(model?: string, attribute?: string); +} \ No newline at end of file diff --git a/data-errors.js b/data-errors.js new file mode 100644 index 0000000..0bc4592 --- /dev/null +++ b/data-errors.js @@ -0,0 +1,31 @@ +const { DataError } = require('@themost/common'); +const { sprintf } = require('sprintf-js'); + +/** + * @private + * @param {string=} entityType + * @param {string=} attribute + * @returns + */ +function formatUnknownAttributeMessage(entityType, attribute) { + if (typeof entityType === 'string' || typeof attribute === 'string') { + return sprintf('Attribute "%s" does not exist on entity type "%s"', attribute, entityType); + } + return null; +} + +class UnknownAttributeError extends DataError { + /** + * + * @param {string=} entityType + * @param {string=} attribute + */ + constructor(entityType, attribute) { + super('ERR_ATTR_UNKNOWN','The specified attribute does not exist on target entity type', formatUnknownAttributeMessage(entityType, attribute), entityType, attribute); + } + +} + +module.exports = { + UnknownAttributeError +} \ No newline at end of file diff --git a/data-filter-resolver.js b/data-filter-resolver.js index ccd8d91..a76ca66 100644 --- a/data-filter-resolver.js +++ b/data-filter-resolver.js @@ -1,10 +1,6 @@ // MOST Web Framework 2.0 Codename Blueshift BSD-3-Clause license Copyright (c) 2017-2022, THEMOST LP All rights reserved var {FunctionContext} = require('./functions'); - -/** - * @module @themost/data/data-filter-resolver - * @ignore - */ +var { UnknownAttributeError } = require('./data-errors'); /** * @ignore @@ -20,11 +16,13 @@ function DataFilterResolver() { DataFilterResolver.prototype.resolveMember = function(member, callback) { if (/\//.test(member)) { var arr = member.split('/'); - callback(null, arr.slice(arr.length-2).join('.')); + return callback(null, arr.slice(arr.length-2).join('.')); } - else { - callback(null, this.viewAdapter.concat('.', member)) + var attribute = this.getAttribute(member); + if (attribute == null) { + return callback(new UnknownAttributeError(this.name, member)); } + return callback(null, this.viewAdapter.concat('.', member)) }; DataFilterResolver.prototype.resolveMethod = function(name, args, callback) { diff --git a/data-queryable.js b/data-queryable.js index 98c2b3c..f621ac9 100644 --- a/data-queryable.js +++ b/data-queryable.js @@ -14,6 +14,7 @@ var Q = require('q'); var aliasProperty = Symbol('alias'); var {hasOwnProperty} = require('./has-own-property'); var {isObjectDeep} = require('./is-object'); +var { UnknownAttributeError } = require('./data-errors'); /** * @param {DataQueryable} target @@ -313,8 +314,8 @@ DataAttributeResolver.prototype.resolveNestedAttributeJoin = function(memberExpr //if the specified member contains '/' e.g. user/name then prepare join var arrMember = memberExprString.split('/'); var attrMember = self.field(arrMember[0]); - if (_.isNil(attrMember)) { - throw new Error(sprintf('The target model does not have an attribute named as %s',arrMember[0])); + if (attrMember == null) { + throw new UnknownAttributeError(self.name, arrMember[0]); } //search for field mapping var mapping = self.inferMapping(arrMember[0]); @@ -322,7 +323,9 @@ DataAttributeResolver.prototype.resolveNestedAttributeJoin = function(memberExpr throw new Error(sprintf('The target model does not have an association defined for attribute named %s',arrMember[0])); } if (mapping.childModel===self.name && mapping.associationType==='association') { - //get parent model + /** + * @type {import('./data-model').DataModel} + */ var parentModel = self.context.model(mapping.parentModel); if (_.isNil(parentModel)) { throw new Error(sprintf('Association parent model (%s) cannot be found.', mapping.parentModel)); @@ -357,6 +360,10 @@ DataAttributeResolver.prototype.resolveNestedAttributeJoin = function(memberExpr parentModel[aliasProperty] = mapping.childField; expr = DataAttributeResolver.prototype.resolveNestedAttributeJoin.call(parentModel, arrMember.slice(1).join('/')); return [].concat(res.$expand).concat(expr); + } else { + // validate attribute name + var attribute = parentModel.getAttribute(arrMember[1]); + Args.check(attribute != null, new UnknownAttributeError(parentModel.name, arrMember[1])); } //--set active field return res.$expand; @@ -407,6 +414,8 @@ DataAttributeResolver.prototype.resolveNestedAttributeJoin = function(memberExpr memberExpr.name = arrMember.join('/'); } } + } else { + throw new UnknownAttributeError(childModel.name, arrMember[1]); } } return res.$expand; @@ -667,6 +676,8 @@ DataAttributeResolver.prototype.resolveJunctionAttributeJoin = function(attr) { if (_.isNil(childModel)) { throw new DataError('EJUNC','The associated model cannot be found.'); } + // validate attribute name + Args.check(childModel.getAttribute(member[1]) != null, new UnknownAttributeError(childModel.name, member[1])); //create new join var alias = field.name + '_' + childModel.name; entity = new QueryEntity(childModel.viewAdapter).as(alias); @@ -983,6 +994,7 @@ DataQueryable.prototype.join = function(model) }); */ DataQueryable.prototype.and = function(attr) { + Args.check(this.query.$where != null, new Error('The where expression has not been initialized.')); if (typeof attr === 'string' && /\//.test(attr)) { this.query.and(DataAttributeResolver.prototype.resolveNestedAttribute.call(this, attr)); return this; @@ -1005,6 +1017,7 @@ DataQueryable.prototype.and = function(attr) { }); */ DataQueryable.prototype.or = function(attr) { + Args.check(this.query.$where != null, new Error('The where expression has not been initialized.')); if (typeof attr === 'string' && /\//.test(attr)) { this.query.or(DataAttributeResolver.prototype.resolveNestedAttribute.call(this, attr)); return this; @@ -1659,7 +1672,7 @@ DataQueryable.prototype.fieldOf = function(attr, alias) { } } if (typeof field === 'undefined' || field === null) - throw new Error(sprintf('The specified field %s cannot be found in target model.', matches[2])); + throw new UnknownAttributeError(this.model.name, matches[2]); if (_.isNil(alias)) { matches = /as\s([\u0020-\u007F\u0080-\uFFFF]+)$/i.exec(attr); if (matches) { @@ -1687,7 +1700,7 @@ DataQueryable.prototype.fieldOf = function(attr, alias) { field = this.model.field(matches[2]); aggr = matches[1]; if (typeof field === 'undefined' || field === null) - throw new Error(sprintf('The specified field %s cannot be found in target model.', matches[2])); + throw new UnknownAttributeError(this.model.name, matches[2]); if (_.isNil(alias)) { matches = /as\s([\u0021-\u007F\u0080-\uFFFF]+)$/i.exec(attr); if (matches) { @@ -1704,7 +1717,7 @@ DataQueryable.prototype.fieldOf = function(attr, alias) { if (matches) { field = this.model.field(matches[1]); if (typeof field === 'undefined' || field === null) - throw new Error(sprintf('The specified field %s cannot be found in target model.', attr)); + throw new UnknownAttributeError(this.model.name, matches[1]); alias = matches[2]; prop = alias || field.property || field.name; return QueryField.select(field.name).from(this.model.viewAdapter).as(prop); @@ -1713,7 +1726,7 @@ DataQueryable.prototype.fieldOf = function(attr, alias) { //try to match field with expression [field] as [alias] or [nested]/[field] as [alias] field = this.model.field(attr); if (typeof field === 'undefined' || field === null) - throw new Error(sprintf('The specified field %s cannot be found in target model.', attr)); + throw new UnknownAttributeError(this.model.name, attr); var f = QueryField.select(field.name).from(this.model.viewAdapter); if (alias) { return f.as(alias); diff --git a/index.d.ts b/index.d.ts index 8f49d06..5890499 100644 --- a/index.d.ts +++ b/index.d.ts @@ -19,3 +19,4 @@ export * from './has-parent-junction'; export * from './data-listeners'; export * from './data-associations'; export * from './data-application'; +export * from './data-errors'; diff --git a/index.js b/index.js index 5fba451..fea3116 100644 --- a/index.js +++ b/index.js @@ -85,6 +85,8 @@ var { DataObjectAssociationListener, DataObjectMultiAssociationError } = require('./data-associations'); var { DataApplication } = require('./data-application'); +var { UknonwnAttributeError } = require('./data-errors'); + module.exports = { TypeParser, PrivilegeType, @@ -165,6 +167,7 @@ module.exports = { EntitySetKind, ODataModelBuilder, ODataConventionModelBuilder, - EntitySetSchemaLoaderStrategy + EntitySetSchemaLoaderStrategy, + UknonwnAttributeError }; diff --git a/most-data-resources.en-us.json b/most-data-resources.en-us.json index 4058355..d549ab4 100644 --- a/most-data-resources.en-us.json +++ b/most-data-resources.en-us.json @@ -73,5 +73,6 @@ "Data model cannot be found.":"Data model cannot be found.", "Object type cannot be empty during remove operation.":"Object type cannot be empty during remove operation.", "The associated model cannot be found.":"The associated model cannot be found.", - "The target model does not have a many to many association defined by the given attribute.":"The target model does not have a many to many association defined by the given attribute." + "The target model does not have a many to many association defined by the given attribute.":"The target model does not have a many to many association defined by the given attribute.", + "Attribute \"%s\" does not exist on entity type \"%s\"": "Attribute \"%s\" does not exist on entity type \"%s\"" } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 611ba52..3404a67 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,7 @@ "@themost/common": "^2.5.11", "@themost/json-logger": "^1.1.0", "@themost/peers": "^1.0.2", - "@themost/query": "^2.5.24", + "@themost/query": "^2.5.27", "@themost/sqlite": "^2.6.16", "@themost/xml": "^2.5.2", "@types/core-js": "^2.5.0", @@ -53,7 +53,7 @@ }, "peerDependencies": { "@themost/common": "^2.5.11", - "@themost/query": "^2.5.22", + "@themost/query": "^2.5.27", "@themost/xml": "^2.5.2" } }, @@ -5194,11 +5194,10 @@ "integrity": "sha512-wlMYRsNWaz5EJ7AwCIA3yw2kHy4p36a4VTz8XmyYTYDYaKgmXjTi6IJPB/+di0mt/rf6NfFfsYwrmytoLseiIg==" }, "node_modules/@themost/query": { - "version": "2.5.24", - "resolved": "https://registry.npmjs.org/@themost/query/-/query-2.5.24.tgz", - "integrity": "sha512-EyMmzPbCwA0RVRPuuGXcQz24AOtp+zdjRRV8gRlctXSQfMhuyywAgQ35KJJYkhYXWFWJxmOCJcj2YO0uCdrj0w==", + "version": "2.5.27", + "resolved": "https://registry.npmjs.org/@themost/query/-/query-2.5.27.tgz", + "integrity": "sha512-3c8kzEEYlkxZblzqgi+Ky9anVMRgcyMCAW/WC5uIEaNIeWaE7SgNIaIi8/ksq6+r6IMQI8hcThFLwHRU30AP6w==", "dev": true, - "license": "BSD-3-Clause", "dependencies": { "@themost/events": "^1.0.5", "async": "^2.6.4", @@ -12977,7 +12976,8 @@ "version": "7.21.0-placeholder-for-preset-env.2", "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", - "dev": true + "dev": true, + "requires": {} }, "@babel/plugin-syntax-async-generators": { "version": "7.8.4", @@ -16030,9 +16030,9 @@ "integrity": "sha512-wlMYRsNWaz5EJ7AwCIA3yw2kHy4p36a4VTz8XmyYTYDYaKgmXjTi6IJPB/+di0mt/rf6NfFfsYwrmytoLseiIg==" }, "@themost/query": { - "version": "2.5.24", - "resolved": "https://registry.npmjs.org/@themost/query/-/query-2.5.24.tgz", - "integrity": "sha512-EyMmzPbCwA0RVRPuuGXcQz24AOtp+zdjRRV8gRlctXSQfMhuyywAgQ35KJJYkhYXWFWJxmOCJcj2YO0uCdrj0w==", + "version": "2.5.27", + "resolved": "https://registry.npmjs.org/@themost/query/-/query-2.5.27.tgz", + "integrity": "sha512-3c8kzEEYlkxZblzqgi+Ky9anVMRgcyMCAW/WC5uIEaNIeWaE7SgNIaIi8/ksq6+r6IMQI8hcThFLwHRU30AP6w==", "dev": true, "requires": { "@themost/events": "^1.0.5", @@ -16294,7 +16294,8 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true + "dev": true, + "requires": {} }, "agent-base": { "version": "6.0.2", @@ -16967,7 +16968,8 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.1.tgz", "integrity": "sha512-+LxW+KLWxu3HW3M2w2ympwtqPrqYRzU8fqi6Fhd18fBALe15blJPI/I4+UHveMVG6lJqB4JNd4UG0S5cnVHwIg==", - "dev": true + "dev": true, + "requires": {} }, "deep-extend": { "version": "0.6.0", @@ -18860,7 +18862,8 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", - "dev": true + "dev": true, + "requires": {} }, "jest-regex-util": { "version": "29.6.3", diff --git a/package.json b/package.json index 253bd06..066d90a 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ }, "peerDependencies": { "@themost/common": "^2.5.11", - "@themost/query": "^2.5.24", + "@themost/query": "^2.5.27", "@themost/xml": "^2.5.2" }, "engines": { @@ -43,7 +43,7 @@ "@themost/common": "^2.5.11", "@themost/json-logger": "^1.1.0", "@themost/peers": "^1.0.2", - "@themost/query": "^2.5.24", + "@themost/query": "^2.5.27", "@themost/sqlite": "^2.6.16", "@themost/xml": "^2.5.2", "@types/core-js": "^2.5.0", diff --git a/spec/DataQueryable.spec.ts b/spec/DataQueryable.spec.ts index 5461322..9a9fc42 100644 --- a/spec/DataQueryable.spec.ts +++ b/spec/DataQueryable.spec.ts @@ -2,6 +2,8 @@ import {DataModelFilterParser} from '../data-model-filter.parser'; import {TestApplication} from './TestApplication'; import {DataContext} from '../types'; import {resolve} from 'path'; +import { UnknownAttributeError } from '../data-errors'; +import { DataQueryable } from '../data-queryable'; describe('DataQueryable', () => { @@ -48,4 +50,81 @@ describe('DataQueryable', () => { expect(() => q.prepare().where('orderedItem/category').equal('Laptops')).toBeTruthy(); }); + it('should validate attribute name', async () => { + const Orders = context.model('Order').silent(); + expect(() => { + return Orders.where('orderStatus1').equal('OrderDelivered') + .orderByDescending('orderDate').take(10); + }).toThrow(new UnknownAttributeError('Order', 'orderStatus1')); + }); + + it('should validate attribute name on filter', async () => { + const Orders = context.model('Order').silent(); + await expect(() => { + return Orders.filterAsync('orderStatus1 eq 4'); + }).rejects.toThrow(new UnknownAttributeError('Order', 'orderStatus1')); + await expect(() => { + return Orders.filterAsync({ + $select: 'orderStatus1' + }); + }).rejects.toThrow(new UnknownAttributeError('Order', 'orderStatus1')); + }); + + it('should validate nested attribute name', async () => { + const Orders = context.model('Order').silent(); + await expect( + Orders.filterAsync('orderStatus1/alternateName eq \'OrderDelivered\'') + ).rejects.toThrow(new UnknownAttributeError('Order', 'orderStatus1')); + + await expect( + Orders.filterAsync({ + $select: 'orderStatus1/alternateName' + }) + ).rejects.toThrow(new UnknownAttributeError('Order', 'orderStatus1')); + }); + + it('should validate nested attribute inside method', async () => { + const Orders = context.model('Order').silent(); + await expect( + Orders.filterAsync('indexof(orderStatus/alternateName1, \'OrderDelivered\') eq 0') + ).rejects.toThrow(new UnknownAttributeError('OrderStatusType', 'alternateName1')); + }); + + it('should validate nested attribute of a one-to-many association', async () => { + const Products = context.model('Product').silent(); + await expect( + Products.filterAsync('orders/orderStatus/alternateName1 eq \'OrderDelivered\'') + ).rejects.toThrow(new UnknownAttributeError('OrderStatusType', 'alternateName1')); + + await expect( + Products.filterAsync({ + $select: 'orders/orderStatus/alternateName1' + }) + ).rejects.toThrow(new UnknownAttributeError('OrderStatusType', 'alternateName1')); + }); + + it('should validate nested attribute of a one-to-many association inside method', async () => { + const Products = context.model('Product').silent(); + await expect( + Products.filterAsync('indexof(orders/orderStatus/alternateName1, \'OrderDelivered\') eq 0') + ).rejects.toThrow(new UnknownAttributeError('OrderStatusType', 'alternateName1')); + }); + + it('should validate nested attribute of a many-to-many association', async () => { + const Products = context.model('Product').silent(); + await expect( + Products.filterAsync('madeIn/name1 eq \'France\'') + ).rejects.toThrow(new UnknownAttributeError('Country', 'name1')); + await expect( + Products.filterAsync({ + $select: 'madeIn/name1' + }) + ).rejects.toThrow(new UnknownAttributeError('Country', 'name1')); + }); + + it('should validate method name', async () => { + const Orders = context.model('Order').silent(); + await expect(Orders.filterAsync('createdBy eq me()')).resolves.toBeInstanceOf(DataQueryable); + }); + });