From 62312788c07fa54fc36cf2bc0a19231e0a906985 Mon Sep 17 00:00:00 2001 From: Kyriakos Barbounakis Date: Thu, 11 Jul 2024 10:44:17 +0300 Subject: [PATCH] handle data association with filter (#152) * handle data association with filter * 2.6.50 --- OnNestedQueryOptionsListener.js | 146 ++++++++++++++++++ data-model.d.ts | 1 + data-model.js | 6 + package-lock.json | 4 +- package.json | 2 +- spec/ZeroOneMultiplicy.spec.ts | 75 +++++++++ spec/test2/config/models/Product.json | 30 ++++ .../config/models/ProductDescription.json | 33 ++++ 8 files changed, 294 insertions(+), 3 deletions(-) create mode 100644 OnNestedQueryOptionsListener.js create mode 100644 spec/test2/config/models/ProductDescription.json diff --git a/OnNestedQueryOptionsListener.js b/OnNestedQueryOptionsListener.js new file mode 100644 index 0000000..9fddf2b --- /dev/null +++ b/OnNestedQueryOptionsListener.js @@ -0,0 +1,146 @@ +const { QueryExpression } = require('@themost/query'); +// eslint-disable-next-line no-unused-vars +const { DataEventArgs } = require('./types'); +const { instanceOf } = require('./instance-of'); +require('@themost/promise-sequence'); + + +/** + * @implements {BeforeExecuteEventListener} + */ +class OnNestedQueryOptionsListener { + /** + * + * @param {DataEventArgs} event + * @param {function} callback + */ + beforeExecute(event, callback) { + OnNestedQueryOptionsListener.prototype.beforeExecuteAsync(event).then(function () { + return callback(); + }).catch(function (err) { + return callback(err); + }); + } + + beforeExecuteAsync(event) { + const query = event.emitter && event.emitter.query; + const context = event.model.context; + if (query == null) { + return Promise.resolve(); + } + // handle only select statements + if (query.$select == null) { + return Promise.resolve(); + } + if (Object.prototype.hasOwnProperty.call(query, '$expand')) { + // exit if expand is null or undefined + if (query.$expand == null) { + return Promise.resolve(); + } + /** + * @type {Array<{ $entity:{ model:string }}>} + */ + const expand = Array.isArray(query.$expand) ? query.$expand : [query.$expand]; + if (expand.length) { + const sources = expand.map(function (item) { + return function () { + // if entity is already a query expression + if (instanceOf(item.$entity, QueryExpression)) { + // do nothing + return Promise.resolve(); + } + if (item.$entity && item.$entity.model) { + // get entity alias (which is a field of current model) + let options = null; + if (item.$entity.$as != null) { + // get current model + let currentModel = event.model; + /** + * get attribute by name e.g. products.getAttributes('productionDescription') + * @type {DataField} + */ + let attribute = currentModel.getAttribute(item.$entity.$as); + if (attribute == null) { + if (item.$with) { + // find another join by name + const [key] = Object.keys(item.$with); + if (key) { + // get name e.g. orderedItem.id => orderedItem + const [name] = key.split('.'); + if (name) { + // find expand by name (which is join alias) + // e.g. expand.find(x => x.$entity.$as === 'orderedItem') + const findExpand = expand.find((x) => x.$entity.$as === name); + if (findExpand) { + // get model by name + // e.g. context.model('Producn') + currentModel = context.model(findExpand.$entity.model); + if (currentModel) { + // get attribute by name + // e.g. products.getAttributes('productionDescription') + attribute = currentModel.getAttribute(item.$entity.$as); + } + } + } + } + } + if (attribute == null) { + return Promise.resolve(); + } + } + const mapping = currentModel.inferMapping(attribute.name); + if (mapping == null) { + return Promise.resolve(); + } + options = mapping.options; + if (options == null) { + return Promise.resolve(); + } + if (options.$filter == null) { + return Promise.resolve(); + } + } + /** + * @type {import('./data-model').DataModel} + */ + const nestedModel = context.model(item.$entity.model); + // if model exists + if (nestedModel != null) { + if (item.$entity.$as != null) { + // change view to follow entity alias defined by the current query + // this operation will be used while parsing filter and creating a new query expression + nestedModel.view = item.$entity.$as; + } + return nestedModel.filterAsync(options).then((q) => { + /** + * @typedef {object} QueryExpressionWithPrepared + * @property {*} $prepared + */ + /** @type {QueryExpressionWithPrepared} */ + const { query } = q; + if (query && query.$prepared) { + item.$with = { + $and: [ + item.$with, + query.$prepared + ] + } + } + return Promise.resolve(); + }); + } + } + return Promise.resolve(); + } + }); + return Promise.sequence(sources); + } + } + return Promise.resolve(); + } + +} + +module.exports = { + OnNestedQueryOptionsListener +} diff --git a/data-model.d.ts b/data-model.d.ts index 7ede3fa..62854a0 100644 --- a/data-model.d.ts +++ b/data-model.d.ts @@ -41,6 +41,7 @@ export declare class DataModel extends SequentialEventEmitter{ search(text: string): DataQueryable; asQueryable(): DataQueryable; filter(params: any, callback?: (err?: Error, res?: any) => void): void; + filterAsync(params: any): Promise; find(obj: any):DataQueryable; select(...attr: any[]): DataQueryable; orderBy(attr: any): DataQueryable; diff --git a/data-model.js b/data-model.js index ace11f6..2578a34 100644 --- a/data-model.js +++ b/data-model.js @@ -33,6 +33,7 @@ var {DataPermissionEventListener} = require('./data-permission'); var {DataField} = require('./types'); var {ZeroOrOneMultiplicityListener} = require('./zero-or-one-multiplicity'); var {OnNestedQueryListener} = require('./OnNestedQueryListener'); +var {OnNestedQueryOptionsListener} = require('./OnNestedQueryOptionsListener'); var {OnExecuteNestedQueryable} = require('./OnExecuteNestedQueryable'); var {hasOwnProperty} = require('./has-own-property'); var {SyncSeriesEventEmitter} = require('@themost/events'); @@ -624,6 +625,7 @@ function unregisterContextListeners() { this.on('before.execute', DataCachingListener.prototype.beforeExecute); } this.on('before.execute', OnExecuteNestedQueryable.prototype.beforeExecute); + this.on('before.execute', OnNestedQueryOptionsListener.prototype.beforeExecute); this.on('before.execute', OnNestedQueryListener.prototype.beforeExecute); //register after execute caching if (this.caching==='always' || this.caching==='conditional') { @@ -920,6 +922,10 @@ DataModel.prototype.filter = function(params, callback) { } }; +DataModel.prototype.filterAsync = function(params) { + return this.filter(params); +}; + /** * Prepares a data query with the given object as parameters and returns the equivalent DataQueryable instance * @param {*} obj - An object which represents the query parameters diff --git a/package-lock.json b/package-lock.json index be01786..5b6638a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@themost/data", - "version": "2.6.49", + "version": "2.6.50", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@themost/data", - "version": "2.6.49", + "version": "2.6.50", "license": "BSD-3-Clause", "dependencies": { "@themost/events": "^1.3.0", diff --git a/package.json b/package.json index c135e48..2ba9456 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@themost/data", - "version": "2.6.49", + "version": "2.6.50", "description": "MOST Web Framework Codename Blueshift - Data module", "main": "index.js", "types": "index.d.ts", diff --git a/spec/ZeroOneMultiplicy.spec.ts b/spec/ZeroOneMultiplicy.spec.ts index 54011a6..02b9b78 100644 --- a/spec/ZeroOneMultiplicy.spec.ts +++ b/spec/ZeroOneMultiplicy.spec.ts @@ -3,6 +3,21 @@ import { DataContext } from '../index'; import { TestApplication } from './TestApplication'; import {TestUtils} from "./adapter/TestUtils"; +declare interface DataContextWithCulture extends DataContext { + culture(value?: string): string; +} + +Object.assign(DataContext.prototype, { + culture(value: string) { + if (typeof value === 'undefined') { + return this._culture || 'en'; + } + this._culture = value; + return this._culture; + } +}) + + fdescribe('ZeroOrOneMultiplicity', () => { let app: TestApplication; let context: DataContext; @@ -38,6 +53,66 @@ fdescribe('ZeroOrOneMultiplicity', () => { }); }); + it('should use $filter expression', async () => { + await TestUtils.executeInTransaction(context, async () => { + const Products = context.model('Product') + let product = await Products.where('name').equal('Samsung Galaxy S4').getItem(); + expect(product).toBeTruthy(); + const productDescriptions = [{ + description: 'This is a new product description', + inLanguage: 'en' + }, { + description: 'Ceci est une nouvelle description de produit', + inLanguage: 'fr' + }]; + await Products.silent().save(Object.assign(product, { + productDescriptions + })); + (context as DataContextWithCulture).culture('fr'); + product = await Products.where('name').equal('Samsung Galaxy S4').expand('productDescription').getItem(); + expect(product).toBeTruthy(); + expect(product.productDescription).toBeTruthy(); + expect(product.productDescription.description).toEqual('Ceci est une nouvelle description de produit'); + }); + }); + + it('should use $filter expression with nested select', async () => { + await TestUtils.executeInTransaction(context, async () => { + const Products = context.model('Product') + let product = await Products.where('name').equal('Samsung Galaxy S4').getItem(); + expect(product).toBeTruthy(); + const productDescriptions = [{ + description: 'This is a new product description', + inLanguage: 'en' + }, { + description: 'Ceci est une nouvelle description de produit', + inLanguage: 'fr' + }]; + await Products.silent().save(Object.assign(product, { + productDescriptions + })); + const Orders = context.model('Order'); + (context as DataContextWithCulture).culture('en'); + let items = await Orders.select( + 'id', + 'orderedItem/name as product', + 'orderedItem/productDescription/description as productDescription' + ).where('orderedItem/name').equal('Samsung Galaxy S4').silent().getItems(); + expect(items).toBeTruthy(); + expect(items.length).toBeGreaterThan(0); + expect(items[0].productDescription).toEqual('This is a new product description'); + (context as DataContextWithCulture).culture('fr'); + items = await Orders.select( + 'id', + 'orderedItem/name as product', + 'orderedItem/productDescription/description as productDescription' + ).where('orderedItem/name').equal('Samsung Galaxy S4').silent().getItems(); + expect(items).toBeTruthy(); + expect(items.length).toBeGreaterThan(0); + expect(items[0].productDescription).toEqual('Ceci est une nouvelle description de produit'); + + }); + }); }); diff --git a/spec/test2/config/models/Product.json b/spec/test2/config/models/Product.json index a55458c..f120884 100644 --- a/spec/test2/config/models/Product.json +++ b/spec/test2/config/models/Product.json @@ -166,6 +166,36 @@ } ] } + }, + { + "name": "productDescription", + "type": "ProductDescription", + "readonly": true, + "many": true, + "multiplicity": "ZeroOrOne", + "mapping": { + "parentModel": "Product", + "parentField": "id", + "childModel": "ProductDescription", + "childField": "product", + "associationType": "association", + "options": { + "$filter" : "inLanguage eq lang()" + } + } + }, + { + "name": "productDescriptions", + "type": "ProductDescription", + "nested": true, + "mapping": { + "parentModel": "Product", + "parentField": "id", + "childModel": "ProductDescription", + "childField": "product", + "associationType": "association", + "cascade": "delete" + } } ], "constraints": [ diff --git a/spec/test2/config/models/ProductDescription.json b/spec/test2/config/models/ProductDescription.json new file mode 100644 index 0000000..1b99a80 --- /dev/null +++ b/spec/test2/config/models/ProductDescription.json @@ -0,0 +1,33 @@ +{ + "$schema": "https://themost-framework.github.io/themost/models/2018/2/schema.json", + "name": "ProductDescription", + "title": "ProductDescription", + "version": "2.0", + "inherits": "StructuredValue", + "fields": [ + { + "name": "description", + "type": "Note" + }, + { + "name": "inLanguage", + "type": "Text", + "size": 5, + "nullable": false + }, + { + "name": "product", + "type": "Product", + "nullable": false + } + ], + "constraints": [ + { + "type": "unique", + "fields": [ + "product", + "inLanguage" + ] + } + ] +} \ No newline at end of file