From 2bf9d234bf43cd5cf2fbfd87284e6acdc7ae9ffa Mon Sep 17 00:00:00 2001 From: Kyriakos Barbounakis Date: Mon, 18 Nov 2024 09:28:54 +0200 Subject: [PATCH] add data value resolver (#177) * add data value resolver * prepare getting attribute for a many-to-many association * resolve array of values * 2.16.0 --- data-attribute-resolver.js | 6 ++ data-queryable.js | 70 +++++++++++++-- data-value-resolver.d.ts | 6 ++ data-value-resolver.js | 150 +++++++++++++++++++++++++++++++++ index.d.ts | 1 + index.js | 5 +- package-lock.json | 4 +- package.json | 2 +- spec/DataValueResolver.spec.ts | 126 +++++++++++++++++++++++++++ 9 files changed, 357 insertions(+), 13 deletions(-) create mode 100644 data-value-resolver.d.ts create mode 100644 data-value-resolver.js create mode 100644 spec/DataValueResolver.spec.ts diff --git a/data-attribute-resolver.js b/data-attribute-resolver.js index e0f2de3..0f32fb6 100644 --- a/data-attribute-resolver.js +++ b/data-attribute-resolver.js @@ -626,6 +626,12 @@ DataAttributeResolver.prototype.resolveJunctionAttributeJoin = function(attr) { //create new join var parentAlias = field.name + '_' + parentModel.name; entity = new QueryEntity(parentModel.viewAdapter).as(parentAlias); + Object.defineProperty(entity, 'model', { + configurable: true, + enumerable: false, + writable: true, + value: parentModel.name + }); expr = QueryUtils.query().where(QueryField.select(mapping.associationObjectField).from(field.name)) .equal(QueryField.select(mapping.parentField).from(parentAlias)); //append join diff --git a/data-queryable.js b/data-queryable.js index 7ae1732..624fc4c 100644 --- a/data-queryable.js +++ b/data-queryable.js @@ -14,7 +14,7 @@ var {hasOwnProperty} = require('./has-own-property'); var { DataAttributeResolver } = require('./data-attribute-resolver'); var { DataExpandResolver } = require('./data-expand-resolver'); var {instanceOf} = require('./instance-of'); - +var { DataValueResolver } = require('./data-value-resolver'); /** * @param {DataQueryable} target */ @@ -235,6 +235,17 @@ DataQueryable.prototype.where = function(attr) { this.query.where(DataAttributeResolver.prototype.resolveNestedAttribute.call(this, attr)); return this; } + // check if attribute defines a many-to-many association + var mapping = this.model.inferMapping(attr); + if (mapping && mapping.associationType === 'junction') { + // append mapping id e.g. groups -> groups/id or members -> members/id etc + let attrId = attr + '/' + mapping.parentField; + if (mapping.parentModel === this.model.name) { + attrId = attr + '/' + mapping.childField; + } + this.query.where(DataAttributeResolver.prototype.resolveNestedAttribute.call(this, attrId)); + return this; + } this.query.where(this.fieldOf(attr)); return this; }; @@ -352,6 +363,17 @@ DataQueryable.prototype.and = function(attr) { this.query.and(DataAttributeResolver.prototype.resolveNestedAttribute.call(this, attr)); return this; } + // check if attribute defines a many-to-many association + var mapping = this.model.inferMapping(attr); + if (mapping && mapping.associationType === 'junction') { + // append mapping id e.g. groups -> groups/id or members -> members/id etc + let attrId = attr + '/' + mapping.parentField; + if (mapping.parentModel === this.model.name) { + attrId = attr + '/' + mapping.childField; + } + this.query.where(DataAttributeResolver.prototype.resolveNestedAttribute.call(this, attrId)); + return this; + } this.query.and(this.fieldOf(attr)); return this; }; @@ -374,6 +396,17 @@ DataQueryable.prototype.or = function(attr) { this.query.or(DataAttributeResolver.prototype.resolveNestedAttribute.call(this, attr)); return this; } + // check if attribute defines a many-to-many association + var mapping = this.model.inferMapping(attr); + if (mapping && mapping.associationType === 'junction') { + // append mapping id e.g. groups -> groups/id or members -> members/id etc + let attrId = attr + '/' + mapping.parentField; + if (mapping.parentModel === this.model.name) { + attrId = attr + '/' + mapping.childField; + } + this.query.where(DataAttributeResolver.prototype.resolveNestedAttribute.call(this, attrId)); + return this; + } this.query.or(this.fieldOf(attr)); return this; }; @@ -417,8 +450,16 @@ function resolveValue(obj) { }); */ DataQueryable.prototype.equal = function(obj) { - - this.query.equal(resolveValue.bind(this)(obj)); + // check if the given object is an array + if (Array.isArray(obj)) { + var resolver = new DataValueResolver(this); + // and resolve each value separately + this.query.equal(obj.map(function(value) { + return resolver.resolve(value); + })); + return this; + } + this.query.equal(new DataValueResolver(this).resolve(obj)); return this; }; @@ -456,7 +497,17 @@ DataQueryable.prototype.is = function(obj) { }); */ DataQueryable.prototype.notEqual = function(obj) { - this.query.notEqual(resolveValue.bind(this)(obj)); + // check if the given object is an array + if (Array.isArray(obj)) { + var resolver = new DataValueResolver(this); + // and resolve each value separately + this.query.notEqual(obj.map(function(value) { + return resolver.resolve(value); + })); + return this; + } + // otherwise resolve the value + this.query.notEqual(new DataValueResolver(this).resolve(obj)); return this; }; // noinspection JSUnusedGlobalSymbols @@ -486,7 +537,7 @@ DataQueryable.prototype.notEqual = function(obj) { 89 Nvidia GeForce GTX 650 Ti Boost 1625.49 2015-11-21 17:29:21.000+02:00 */ DataQueryable.prototype.greaterThan = function(obj) { - this.query.greaterThan(resolveValue.bind(this)(obj)); + this.query.greaterThan(new DataValueResolver(this).resolve(obj)); return this; }; @@ -507,7 +558,7 @@ DataQueryable.prototype.greaterThan = function(obj) { }); */ DataQueryable.prototype.greaterOrEqual = function(obj) { - this.query.greaterOrEqual(resolveValue.bind(this)(obj)); + this.query.greaterOrEqual(new DataValueResolver(this).resolve(obj)); return this; }; @@ -544,7 +595,7 @@ DataQueryable.prototype.bit = function(value, result) { * @returns {DataQueryable} */ DataQueryable.prototype.lowerThan = function(obj) { - this.query.lowerThan(resolveValue.bind(this)(obj)); + this.query.lowerThan(new DataValueResolver(this).resolve(obj)); return this; }; @@ -565,7 +616,7 @@ DataQueryable.prototype.lowerThan = function(obj) { }); */ DataQueryable.prototype.lowerOrEqual = function(obj) { - this.query.lowerOrEqual(resolveValue.bind(this)(obj)); + this.query.lowerOrEqual(new DataValueResolver(this).resolve(obj)); return this; }; // noinspection JSUnusedGlobalSymbols @@ -745,7 +796,8 @@ DataQueryable.prototype.notContains = function(value) { 440 Bose SoundLink Bluetooth Mobile Speaker II HS5288 155.27 */ DataQueryable.prototype.between = function(value1, value2) { - this.query.between(resolveValue.bind(this)(value1), resolveValue.bind(this)(value2)); + const resolver = new DataValueResolver(this); + this.query.between(resolver.resolve(value1), resolver.resolve(value2)); return this; }; diff --git a/data-value-resolver.d.ts b/data-value-resolver.d.ts new file mode 100644 index 0000000..32fa4a7 --- /dev/null +++ b/data-value-resolver.d.ts @@ -0,0 +1,6 @@ +import {QueryExpression} from '@themost/query'; + +export declare class DataValueResolver { + constructor(target: any ); + resolve(value: any): any; +} diff --git a/data-value-resolver.js b/data-value-resolver.js new file mode 100644 index 0000000..834ecb7 --- /dev/null +++ b/data-value-resolver.js @@ -0,0 +1,150 @@ +const {DataAttributeResolver} = require('./data-attribute-resolver'); +const {isObjectDeep} = require('./is-object'); +const {sprintf} = require('sprintf-js'); + +/** + * @class DataValueResolver + * @param {import('./index').DataQueryable} target + * @constructor + */ +function DataValueResolver(target) { + Object.defineProperty(this, 'target', { get: function() { + return target; + }, configurable:false, enumerable:false}); +} + +DataValueResolver.prototype.resolve = function(value) { + /** + * @type {DataQueryable} + */ + var target = this.target; + if (typeof value === 'string' && /^\$it\//.test(value)) { + var attr = value.replace(/^\$it\//,''); + if (DataAttributeResolver.prototype.testNestedAttribute(attr)) { + return DataAttributeResolver.prototype.resolveNestedAttribute.call(target, attr); + } + else { + attr = DataAttributeResolver.prototype.testAttribute(attr); + if (attr) { + return target.fieldOf(attr.name); + } + } + } + if (isObjectDeep(value)) { + // try to get in-process left operand + // noinspection JSUnresolvedReference + var left = target.query.privates && target.query.privates.property; + if (typeof left === 'string' && /\./.test(left)) { + var members = left.split('.'); + if (Array.isArray(members)) { + // try to find member mapping + /** + * @type {import('./data-model').DataModel} + */ + var model = target.model; + var mapping; + var attribute; + var index = 0; + var context = target.model.context; + // if the first segment contains the view adapter name + if (members[0] === target.model.viewAdapter) { + // move next + index++; + } else if (target.query.$expand != null) { + // try to find if the first segment is contained in the collection of joined entities + var joins = Array.isArray(target.query.$expand) ? target.query.$expand : [ target.query.$expand ]; + if (joins.length) { + var found = joins.find(function(x) { + return x.$entity && x.$entity.$as === members[0]; + }); + if (found) { + var mapping1 = model.inferMapping(found.$entity.$as); + if (mapping1 && mapping1.associationType === 'junction') { + // get next segment of members + var nextMember = members[index + 1]; + if (nextMember === mapping1.associationObjectField) { + // the next segment is the association object field + // e.g. groups/group + model = context.model(mapping1.parentModel); + members[index + 1] = mapping1.parentField; + } else if (nextMember === mapping1.associationValueField) { + // the next segment is the association value field + // e.g. groups/user + model = context.model(mapping1.childModel); + members[index + 1] = mapping1.childField; + } else if (model.name === mapping1.parentModel) { + model = context.model(mapping1.childModel); + } else { + model = context.model(mapping1.parentModel); + } + } else if (found.$entity.model != null) { + model = context.model(found.$entity.model); + } else { + throw new Error(sprintf('Expected a valid mapping for property "%s"', found.$entity.$as)); + } + index++; + } + } + } + + var mapValue = function(x) { + if (Object.hasOwnProperty.call(x, name)) { + return x[name]; + } + throw new Error(sprintf('Invalid value for property "%s"', members[members.length - 1])); + } + + while (index < members.length) { + mapping = model.inferMapping(members[index]); + if (mapping) { + if (mapping.associationType === 'association' && mapping.childModel === model.name) { + model = context.model(mapping.parentModel); + if (model) { + attribute = model.getAttribute(mapping.parentField); + } + } else if (mapping.associationType === 'association' && mapping.parentModel === model.name) { + model = context.model(mapping.childModel); + if (model) { + attribute = model.getAttribute(mapping.childField); + } + } else if (mapping.associationType === 'junction' && mapping.childModel === model.name) { + model = context.model(mapping.parentModel); + if (model) { + attribute = model.getAttribute(mapping.parentField); + } + } else if (mapping.associationType === 'junction' && mapping.parentModel === model.name) { + model = context.model(mapping.childModel); + if (model) { + attribute = model.getAttribute(mapping.childField); + } + } + } else { + // if mapping is not found, and we are in the last segment + // try to find if this last segment is a field of the current model + if (index === members.length - 1) { + attribute = model.getAttribute(members[index]); + break; + } + attribute = null; + model = null; + break; + } + index++; + } + if (attribute) { + var name = attribute.property || attribute.name; + if (Array.isArray(value)) { + return value.map(function(x) { + return mapValue(x); + }); + } else { + return mapValue(value); + } + } + } + } + } + return value; +} + +module.exports = { DataValueResolver }; diff --git a/index.d.ts b/index.d.ts index b6ad3c5..969dc14 100644 --- a/index.d.ts +++ b/index.d.ts @@ -20,3 +20,4 @@ export * from './data-listeners'; export * from './data-associations'; export * from './data-application'; export * from './UnattendedMode'; +export * from './data-value-resolver'; diff --git a/index.js b/index.js index 715733d..75b9afe 100644 --- a/index.js +++ b/index.js @@ -88,6 +88,8 @@ var { DataApplication } = require('./data-application'); var { executeInUnattendedMode, executeInUnattendedModeAsync, enableUnattendedExecution, disableUnattendedExecution } = require('./UnattendedMode'); +var { DataValueResolver } = require('./data-value-resolver'); + module.exports = { TypeParser, PrivilegeType, @@ -171,6 +173,7 @@ module.exports = { executeInUnattendedMode, executeInUnattendedModeAsync, enableUnattendedExecution, - disableUnattendedExecution + disableUnattendedExecution, + DataValueResolver }; diff --git a/package-lock.json b/package-lock.json index 89ebe76..ae5e272 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@themost/data", - "version": "2.15.1", + "version": "2.16.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@themost/data", - "version": "2.15.1", + "version": "2.16.0", "license": "BSD-3-Clause", "dependencies": { "@themost/events": "^1.0.5", diff --git a/package.json b/package.json index 50ec00c..93c12ea 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@themost/data", - "version": "2.15.1", + "version": "2.16.0", "description": "MOST Web Framework Codename Blueshift - Data module", "main": "index.js", "scripts": { diff --git a/spec/DataValueResolver.spec.ts b/spec/DataValueResolver.spec.ts new file mode 100644 index 0000000..06692b3 --- /dev/null +++ b/spec/DataValueResolver.spec.ts @@ -0,0 +1,126 @@ +import { resolve } from 'path'; +import { DataContext } from '../index'; +import {TestApplication, TestApplication2} from './TestApplication'; + +describe('DataValueResolver', () => { + let app: TestApplication; + let context: DataContext; + beforeAll(async () => { + app = new TestApplication2(); + context = app.createContext(); + }); + afterAll(async () => { + await app.finalize(); + }) + it('should resolve value from an associated parent object', async () => { + const product = await context.model('Product').where('name').equal('Western Digital My Passport Slim (1TB)').getItem(); + expect(product).toBeTruthy(); + const Orders = context.model('Order').silent(); + let items = await Orders.where('orderedItem').equal(product).getItems(); + expect(items.length).toBeTruthy(); + for (const order of items) { + expect(order.orderedItem.id).toEqual(product.id); + } + }); + + it('should fail resolving value from an associated parent object', async () => { + const product = await context.model('Product').where('name').equal('Western Digital My Passport Slim (1TB)').getItem(); + expect(product).toBeTruthy(); + // remove product id + delete product.id; + const Orders = context.model('Order').silent(); + expect(() => Orders.where('orderedItem').equal(product)).toThrowError('Invalid value for property "orderedItem"'); + }); + + it('should resolve value from a property of an associated parent object', async () => { + const product = await context.model('Product').where('name').equal('Western Digital My Passport Slim (1TB)').getItem(); + expect(product).toBeTruthy(); + const Orders = context.model('Order').silent(); + let items = await Orders.where('orderedItem/name').equal(product).getItems(); + expect(items.length).toBeTruthy(); + for (const order of items) { + expect(order.orderedItem.id).toEqual(product.id); + } + }); + + it('should resolve value from an associated child object', async () => { + const Products = context.model('Product').silent(); + const product = await Products.where('name').equal('Western Digital My Passport Slim (1TB)').getItem(); + expect(product).toBeTruthy(); + const Orders = context.model('Order').silent(); + const lastOrder = await Orders.where('orderedItem').equal(product) + .orderByDescending('dateCreated').expand('customer').getItem(); + expect(lastOrder).toBeTruthy(); + const { customer } = lastOrder; + const items = await Products.where('orders/customer').equal(customer).getItems(); + expect(items.length).toBeTruthy(); + const found = items.find((x) => x.id === product.id); + expect(found).toBeTruthy(); + }); + + it('should resolve value from an associated child nested object', async () => { + const Products = context.model('Product').silent(); + const product = await Products.where('name').equal('Western Digital My Passport Slim (1TB)').getItem(); + expect(product).toBeTruthy(); + const Orders = context.model('Order').silent(); + const lastOrder = await Orders.where('orderedItem').equal(product) + .orderByDescending('dateCreated').expand({ + name: 'customer', + options: { $expand: 'address' } + }).getItem(); + expect(lastOrder).toBeTruthy(); + const { customer } = lastOrder; + const items = await Products.where('orders/customer/address').equal(customer.address).getItems(); + expect(items.length).toBeTruthy(); + const found = items.find((x) => x.id === product.id); + expect(found).toBeTruthy(); + }); + + it('should resolve value from a property of an associated child object', async () => { + const Products = context.model('Product').silent(); + const product = await Products.where('name').equal('Western Digital My Passport Slim (1TB)').getItem(); + expect(product).toBeTruthy(); + const Orders = context.model('Order').silent(); + const lastOrder = await Orders.where('orderedItem').equal(product.id) + .orderByDescending('dateCreated').expand('customer').getItem(); + expect(lastOrder).toBeTruthy(); + const { customer } = lastOrder; + const items = await Products.where('orders/customer/id').equal(customer).getItems(); + expect(items.length).toBeTruthy(); + const found = items.find((x) => x.id === product.id); + expect(found).toBeTruthy(); + }); + + it('should fail resolving value from an associated child object', async () => { + const Products = context.model('Product').silent(); + const product = await Products.where('name').equal('Western Digital My Passport Slim (1TB)').getItem(); + expect(product).toBeTruthy(); + const Orders = context.model('Order').silent(); + const lastOrder = await Orders.where('orderedItem').equal(product) + .orderByDescending('dateCreated').expand('customer').getItem(); + expect(lastOrder).toBeTruthy(); + const { customer } = lastOrder; + delete customer.id; + expect(() => Products.where('orders/customer').equal(customer)).toThrowError('Invalid value for property "customer"'); + }); + + it('should resolve value from an object based on a many-to-many association', async () => { + const Users = context.model('User').silent(); + const Groups = context.model('Group').silent(); + const group = await Groups.where('name').equal('Administrators').getItem(); + expect(group).toBeTruthy(); + let items = await Users.where('groups').equal(group).getItems(); + expect(items.length).toBeTruthy(); + }); + + it('should resolve value from an object based on a parent/child many-to-many association', async () => { + const Users = context.model('User').silent(); + const Groups = context.model('Group').silent(); + const user = await Users.where('name').equal('alexis.rees@example.com').getItem(); + expect(user).toBeTruthy(); + let items = await Groups.where('members').equal(user).getItems(); + expect(items.length).toBeTruthy(); + }); + + +});