diff --git a/jest.setup.js b/jest.setup.js index 51d9b02..570fb02 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -2,6 +2,8 @@ require('dotenv').config(); const { JsonLogger } = require('@themost/json-logger'); const { TraceUtils } = require('@themost/common'); process.env.NODE_ENV = 'development'; -TraceUtils.useLogger(new JsonLogger()); +TraceUtils.useLogger(new JsonLogger({ + format: 'raw' +})); /* global jest */ jest.setTimeout(30000); \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 68e0028..9662483 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@themost/sqlite", - "version": "2.9.0", + "version": "2.9.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@themost/sqlite", - "version": "2.9.0", + "version": "2.9.1", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { @@ -27,7 +27,7 @@ "@rollup/plugin-babel": "^5.3.1", "@rollup/plugin-commonjs": "^22.0.0", "@themost/common": "^2.11.0", - "@themost/data": "^2.18.1", + "@themost/data": "^2.18.2", "@themost/json-logger": "^1.1.0", "@themost/peers": "^1.0.2", "@themost/query": "^2.14.7", @@ -3340,9 +3340,9 @@ } }, "node_modules/@themost/data": { - "version": "2.18.1", - "resolved": "https://registry.npmjs.org/@themost/data/-/data-2.18.1.tgz", - "integrity": "sha512-y2hDeFG7hYX8keOUlf1JZR65en3di8C5n32cEAwiXVPtVwVFRvOUyXOyGYb8biqZ81IhbIADUmEgZlr6yGxbyw==", + "version": "2.18.2", + "resolved": "https://registry.npmjs.org/@themost/data/-/data-2.18.2.tgz", + "integrity": "sha512-oMa8N0B0Z0vWzZIoG9oPlajK7BGj4WKsJL4EU2SMjvFus6Fx2KsFrXxBdXXSKsLhc0Vqjki92yWQIZtdzaeRKg==", "dev": true, "license": "BSD-3-Clause", "dependencies": { diff --git a/package.json b/package.json index 5adba7a..5c2c3c8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@themost/sqlite", - "version": "2.9.0", + "version": "2.9.1", "description": "MOST Web Framework SQLite Adapter", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -48,7 +48,7 @@ "@rollup/plugin-babel": "^5.3.1", "@rollup/plugin-commonjs": "^22.0.0", "@themost/common": "^2.11.0", - "@themost/data": "^2.18.1", + "@themost/data": "^2.18.2", "@themost/json-logger": "^1.1.0", "@themost/peers": "^1.0.2", "@themost/query": "^2.14.7", diff --git a/spec/QueryExpression.selectJson.spec.js b/spec/QueryExpression.selectJson.spec.js index c980cb8..df766db 100644 --- a/spec/QueryExpression.selectJson.spec.js +++ b/spec/QueryExpression.selectJson.spec.js @@ -4,6 +4,10 @@ import {MemberExpression, MethodCallExpression, QueryEntity, QueryExpression, Qu import { SqliteFormatter } from '../src'; import SimpleOrderSchema from './config/models/SimpleOrder.json'; import {TestApplication} from './TestApplication'; +import { TraceUtils } from '@themost/common'; +import { DataPermissionEventListener } from '@themost/data'; +import { promisify } from 'util'; +const beforeExecuteAsync = promisify(DataPermissionEventListener.prototype.beforeExecute); /** * @param { import('../src').SqliteAdapter } db @@ -328,9 +332,7 @@ describe('SqlFormatter', () => { select.push({ customer: { $jsonObject: [ - 'familyName', new QueryField('familyName').from('customer'), - 'givenName', new QueryField('givenName').from('customer'), ] } @@ -369,18 +371,14 @@ describe('SqlFormatter', () => { select.push({ customer: { $jsonObject: [ - 'familyName', new QueryField('familyName').from('customers'), - 'givenName', new QueryField('givenName').from('customers'), ] } }, { orderStatus: { $jsonObject: [ - 'name', new QueryField('name').from('orderStatusTypes'), - 'alternateName', new QueryField('alternateName').from('orderStatusTypes'), ] } @@ -401,4 +399,206 @@ describe('SqlFormatter', () => { }); }); + it('should use json queries for expand entities', async () => { + // set context user + context.user = { + name: 'james.may@example.com' + }; + let start= new Date().getTime(); + const items = await context.model('Order').asQueryable().select( + 'id', 'orderDate', 'orderStatus', 'customer', 'orderedItem' + ).expand('customer', 'orderStatus', 'orderedItem').getItems(); + let end = new Date().getTime(); + TraceUtils.log('Elapsed time: ' + (end-start) + 'ms'); + expect(items.length).toBeTruthy(); + // create ad-hoc query + const { viewAdapter: Orders } = context.model('Order'); + const { viewAdapter: People } = context.model('Person'); + const { viewAdapter: Products } = context.model('Product'); + const { viewAdapter: OrderStatusTypes } = context.model('OrderStatusType'); + const personAttributes = context.model('Person').select().query.$select[People].map((x) => { + return x.from('customer'); + }); + const productAttributes = context.model('Product').select().query.$select[Products].map((x) => { + return x.from('orderedItem'); + }); + const orderStatusAttributes = context.model('OrderStatusType').select().query.$select[OrderStatusTypes].map((x) => { + return x.from('orderStatus'); + }); + const q = new QueryExpression().select( + new QueryField('id').from(Orders), + new QueryField('orderDate').from(Orders), + new QueryField({ + customer: { + $jsonObject: personAttributes + } + }), + new QueryField({ + product: { + $jsonObject: productAttributes + } + }), + new QueryField({ + orderStatus: { + $jsonObject: orderStatusAttributes + } + }) + ).from(Orders).join(new QueryEntity(People).as('customer')).with( + new QueryExpression().where( + new QueryField('customer').from(Orders) + ).equal( + new QueryField('id').from('customer') + ) + ).join(new QueryEntity(Products).as('orderedItem')).with( + new QueryExpression().where( + new QueryField('orderedItem').from(Orders) + ).equal( + new QueryField('id').from('orderedItem') + ) + ).join(new QueryEntity(OrderStatusTypes).as('orderStatus')).with( + new QueryExpression().where( + new QueryField('orderStatus').from(Orders) + ).equal( + new QueryField('id').from('orderStatus') + ) + ).where(new QueryField('email').from('customer')).equal(context.user.name); + + start= new Date().getTime(); + const customerOrders = await context.db.executeAsync(q, []); + end = new Date().getTime(); + TraceUtils.log('Elapsed time: ' + (end-start) + 'ms'); + expect(customerOrders.length).toBeTruthy(); + expect(items.length).toEqual(customerOrders.length); + }); + + it('should use json queries and validate permission', async () => { + // set context user + context.user = { + name: 'james.may@example.com' + }; + const queryOrders = context.model('Order').asQueryable().select().flatten(); + const { viewAdapter: Orders } = queryOrders.model; + expect(queryOrders).toBeTruthy(); + // prepare query for customer + const queryPeople = context.model('Person').asQueryable().select().flatten(); + await beforeExecuteAsync({ + model: queryPeople.model, + emitter: queryPeople, + query: queryPeople.query, + }); + expect(queryPeople).toBeTruthy(); + // prepare query for order status + const queryOrderStatus = context.model('OrderStatusType').asQueryable().select().flatten(); + await beforeExecuteAsync({ + model: queryOrderStatus.model, + emitter: queryOrderStatus, + query: queryOrderStatus.query, + }); + // prepare query for ordered item + const queryProducts = context.model('Product').asQueryable().select().flatten(); + await beforeExecuteAsync({ + model: queryProducts.model, + emitter: queryProducts, + query: queryProducts.query, + }); + + // phase 1: join customers in order to get customer as json object + const { viewAdapter: People } = queryPeople.model; + // select customer as json object + const selectCustomer = new QueryField({ + customer: { + $jsonObject: queryPeople.query.$select[People].map((x) => { + return x.from('customer'); + }) + } + }); + // remove select arguments from nested query and push a wildcard select + // important note: this operation reduces the size of the subquery used for join entity + queryPeople.query.$select[People] = [new QueryField(`${People}.*`)]; + // join entity + queryOrders.query.join(queryPeople.query.as('customer')).with( + new QueryExpression().where( + new QueryField('customer').from(Orders) + ).equal( + new QueryField('id').from('customer') + ) + ) + // append customer json object + + const selectOrders = queryOrders.query.$select[Orders]; + // remove previoulsy selected customer field + let removeIndex = selectOrders.findIndex((x) => x instanceof QueryField && x.$name === `${Orders}.customer`); + if (removeIndex >= 0) { + selectOrders.splice(removeIndex, 1); + } + // add customer json object + selectOrders.push(selectCustomer); + + // phase 2: join ordered items in order to get ordered item as json object + const { viewAdapter: Products } = queryProducts.model; + // select ordered item as json object + const selectOrderedItem = new QueryField({ + orderedItem: { + $jsonObject: queryProducts.query.$select[Products].map((x) => { + return x.from('orderedItem'); + }) + } + }); + // remove select arguments from nested query and push a wildcard select + // important note: this operation reduces the size of the subquery used for join entity + queryProducts.query.$select[Products] = [new QueryField(`${Products}.*`)]; + // join entity + queryOrders.query.join(queryProducts.query.as('orderedItem')).with( + new QueryExpression().where( + new QueryField('orderedItem').from(Orders) + ).equal( + new QueryField('id').from('orderedItem') + ) + ) + removeIndex = selectOrders.findIndex((x) => x instanceof QueryField && x.$name === `${Orders}.orderedItem`); + if (removeIndex >= 0) { + selectOrders.splice(removeIndex, 1); + } + // add ordered json object + selectOrders.push(selectOrderedItem); + + // phase 3: join order status in order to get order status as json object + const { viewAdapter: OrderStatusTypes } = queryOrderStatus.model; + // select order status as json object + const selectOrderStatus = new QueryField({ + orderStatus: { + $jsonObject: queryOrderStatus.query.$select[OrderStatusTypes].map((x) => { + return x.from('orderStatus'); + }) + } + }); + // remove select arguments from nested query and push a wildcard select + // important note: this operation reduces the size of the subquery used for join entity + queryOrderStatus.query.$select[OrderStatusTypes] = [new QueryField(`${OrderStatusTypes}.*`)]; + // join entity + queryOrders.query.join(queryOrderStatus.query.as('orderStatus')).with( + new QueryExpression().where( + new QueryField('orderStatus').from(Orders) + ).equal( + new QueryField('id').from('orderStatus') + ) + ); + removeIndex = selectOrders.findIndex((x) => x instanceof QueryField && x.$name === `${Orders}.orderStatus`); + if (removeIndex >= 0) { + selectOrders.splice(removeIndex, 1); + } + // add order status json object + selectOrders.push(selectOrderStatus); + + let start= new Date().getTime(); + const items = await queryOrders.getItems(); + let end = new Date().getTime(); + TraceUtils.log('Elapsed time: ' + (end-start) + 'ms'); + expect(items.length).toBeTruthy(); + for (const item of items) { + expect(item.customer).toBeInstanceOf(Object); + expect(item.orderedItem).toBeInstanceOf(Object); + } + }); + }); diff --git a/spec/db/local.db b/spec/db/local.db index 7f91614..9b4abf7 100644 Binary files a/spec/db/local.db and b/spec/db/local.db differ diff --git a/src/SqliteFormatter.js b/src/SqliteFormatter.js index a7028f3..fb28083 100644 --- a/src/SqliteFormatter.js +++ b/src/SqliteFormatter.js @@ -1,7 +1,7 @@ // MOST Web Framework Codename Zero Gravity Copyright (c) 2017-2022, THEMOST LP import { sprintf } from 'sprintf-js'; -import { SqlFormatter } from '@themost/query'; +import { SqlFormatter, QueryField } from '@themost/query'; const REGEXP_SINGLE_QUOTE=/\\'/g; const SINGLE_QUOTE_ESCAPE ='\'\''; const REGEXP_DOUBLE_QUOTE=/\\"/g; @@ -337,24 +337,33 @@ class SqliteFormatter extends SqlFormatter { } } - /** - * @param {...*} expr - */ - // eslint-disable-next-line no-unused-vars - $json(expr) { - const args = Array.from(arguments); - return this.$jsonObject(...args); - } - /** * @param {...*} expr */ // eslint-disable-next-line no-unused-vars $jsonObject(expr) { - const args = Array.from(arguments).map((arg) => { - return this.escape(arg) - }); - return `json_object(${args.join(',')})`; + // expected an array of QueryField objects + const args = Array.from(arguments).reduce((previous, current) => { + // get the first key of the current object + let [name] = Object.keys(current); + let value; + // if the name is not a string then throw an error + if (typeof name !== 'string') { + throw new Error('Invalid json object expression. The attribute name cannot be determined.'); + } + // if the given name is a dialect function (starts with $) then use the current value as is + // otherwise create a new QueryField object + if (name.startsWith('$')) { + value = new QueryField(current[name]); + name = value.getName(); + } else { + value = current instanceof QueryField ? new QueryField(current[name]) : current[name]; + } + // escape json attribute name and value + previous.push(this.escape(name), this.escape(value)); + return previous; + }, []); + return `json_object(${args.join(',')})`;; } }