diff --git a/package-lock.json b/package-lock.json index 9662483..a0b2a05 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,18 @@ { "name": "@themost/sqlite", - "version": "2.9.1", + "version": "2.9.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@themost/sqlite", - "version": "2.9.1", + "version": "2.9.2", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { "@themost/events": "^1.5.0", "async": "^2.6.4", + "lodash": "^4.17.21", "sprintf-js": "^1.1.2", "sqlite3": "^5.1.7", "unzipper": "^0.12.3" @@ -8538,7 +8539,8 @@ "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" }, "node_modules/lodash.debounce": { "version": "4.0.8", diff --git a/package.json b/package.json index 5c2c3c8..6a62a95 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@themost/sqlite", - "version": "2.9.1", + "version": "2.9.2", "description": "MOST Web Framework SQLite Adapter", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -30,6 +30,7 @@ "dependencies": { "@themost/events": "^1.5.0", "async": "^2.6.4", + "lodash": "^4.17.21", "sprintf-js": "^1.1.2", "sqlite3": "^5.1.7", "unzipper": "^0.12.3" diff --git a/spec/QueryExpression.selectJson.spec.js b/spec/QueryExpression.selectJson.spec.js index df766db..53ddab0 100644 --- a/spec/QueryExpression.selectJson.spec.js +++ b/spec/QueryExpression.selectJson.spec.js @@ -17,7 +17,9 @@ async function createSimpleOrders(db) { const { source } = SimpleOrderSchema; const exists = await db.table(source).existsAsync(); if (!exists) { - await db.table(source).createAsync(SimpleOrderSchema.fields); + await db.table(source).createAsync(SimpleOrderSchema.fields); + } else { + return; } // get some orders const orders = await db.executeAsync( @@ -61,7 +63,19 @@ async function createSimpleOrders(db) { return {id, streetAddress, postalCode, addressLocality, addressCountry, telephone }; }), [] ); - // get + + const shuffleArray = (array) => { + for (let i = array.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [array[i], array[j]] = [array[j], array[i]]; + } + return array; + }; + + const getRandomItems = (array, numItems) => { + const shuffledArray = shuffleArray([...array]); + return shuffledArray.slice(0, numItems); + }; const items = orders.map((order) => { const { orderDate, discount, discountCode, orderNumber, paymentDue, dateCreated, dateModified, createdBy, modifiedBy } = order; @@ -73,6 +87,8 @@ async function createSimpleOrders(db) { customer.address = postalAddresses.find((x) => x.id === customer.address); delete customer.address?.id; } + // get 2 random payment methods + const additionalPaymentMethods = getRandomItems(paymentMethods, 2); return { orderDate, discount, @@ -82,6 +98,7 @@ async function createSimpleOrders(db) { orderStatus, orderedItem, paymentMethod, + additionalPaymentMethods, customer, dateCreated, dateModified, @@ -601,4 +618,98 @@ describe('SqlFormatter', () => { } }); + + it('should return json arrays', async () => { + // set context user + context.user = { + name: 'alexis.rees@example.com' + }; + + const queryPeople = context.model('Person').asQueryable().select( + 'id', 'familyName', 'givenName', 'jobTitle', 'email' + ).flatten(); + await beforeExecuteAsync({ + model: queryPeople.model, + emitter: queryPeople, + query: queryPeople.query, + }); + const { viewAdapter: People } = queryPeople.model; + const queryOrders = context.model('Order').asQueryable().select( + 'id', 'orderDate', 'orderStatus', 'orderedItem', 'customer' + ).flatten(); + const { viewAdapter: Orders } = queryOrders.model; + // prepare query for each customer + queryOrders.query.where( + new QueryField('customer').from(Orders) + ).equal( + new QueryField('id').from(People) + ); + const selectPeople = queryPeople.query.$select[People]; + // add orders as json array + selectPeople.push({ + orders: { + $jsonArray: [ + queryOrders.query + ] + } + }); + const start= new Date().getTime(); + const items = await queryPeople.take(50).getItems(); + const end = new Date().getTime(); + TraceUtils.log('Elapsed time: ' + (end-start) + 'ms'); + expect(items.length).toBeTruthy(); + for (const item of items) { + expect(Array.isArray(item.orders)).toBeTruthy(); + for (const order of item.orders) { + expect(order.customer).toEqual(item.id); + } + + } + }); + + it('should parse string as json array', async () => { + // set context user + context.user = { + name: 'alexis.rees@example.com' + }; + const { viewAdapter: People } = context.model('Person'); + const query = new QueryExpression().select( + 'id', 'familyName', 'givenName', 'jobTitle', 'email', + new QueryField({ + tags: { + $jsonArray: [ + new QueryField({ + $value: '[ "user", "customer", "admin" ]' + }) + ] + } + }) + ).from(People).where('email').equal(context.user.name); + const [item] = await context.db.executeAsync(query); + expect(item).toBeTruthy(); + }); + + it('should parse array as json array', async () => { + // set context user + context.user = { + name: 'alexis.rees@example.com' + }; + const { viewAdapter: People } = context.model('Person'); + const query = new QueryExpression().select( + 'id', 'familyName', 'givenName', 'jobTitle', 'email', + new QueryField({ + tags: { + $jsonArray: [ + { + $value: [ 'user', 'customer', 'admin' ] + } + ] + } + }) + ).from(People).where('email').equal(context.user.name); + const [item] = await context.db.executeAsync(query); + expect(item).toBeTruthy(); + expect(Array.isArray(item.tags)).toBeTruthy(); + expect(item.tags).toEqual([ 'user', 'customer', 'admin' ]); + }); }); diff --git a/spec/config/models/SimpleOrder.json b/spec/config/models/SimpleOrder.json index 4c136d4..590ee21 100644 --- a/spec/config/models/SimpleOrder.json +++ b/spec/config/models/SimpleOrder.json @@ -172,6 +172,13 @@ "type": "Integer", "calculation": "javascript:return this.user();", "readonly": true + }, + { + "name": "additionalPaymentMethods", + "type": "Json", + "additionalType": "PaymentMethod", + "expandable": true, + "many": true } ], "views": [ diff --git a/spec/db/local.db b/spec/db/local.db index 9b4abf7..573c022 100644 Binary files a/spec/db/local.db and b/spec/db/local.db differ diff --git a/src/SqliteAdapter.js b/src/SqliteAdapter.js index 6e7f352..bd6a84f 100644 --- a/src/SqliteAdapter.js +++ b/src/SqliteAdapter.js @@ -44,7 +44,7 @@ function onReceivingJsonObject(event) { if (typeof key !== 'string') { return false; } - return x[key].$jsonObject != null || x[key].$json != null; + return x[key].$jsonObject != null || x[key].$jsonGroupArray != null || x[key].$jsonArray != null; }).map((x) => { return Object.keys(x)[0]; }); diff --git a/src/SqliteFormatter.js b/src/SqliteFormatter.js index fb28083..913872c 100644 --- a/src/SqliteFormatter.js +++ b/src/SqliteFormatter.js @@ -2,6 +2,7 @@ import { sprintf } from 'sprintf-js'; import { SqlFormatter, QueryField } from '@themost/query'; +import { isObjectDeep } from './isObjectDeep'; const REGEXP_SINGLE_QUOTE=/\\'/g; const SINGLE_QUOTE_ESCAPE ='\'\''; const REGEXP_DOUBLE_QUOTE=/\\"/g; @@ -52,6 +53,19 @@ class SqliteFormatter extends SqlFormatter { if (value instanceof Date) { return this.escapeDate(value); } + // serialize array of objects as json array + if (Array.isArray(value)) { + // find first non-object value + const index = value.filter((x) => { + return x != null; + }).findIndex((x) => { + return isObjectDeep(x) === false; + }); + // if all values are objects + if (index === -1) { + return this.escape(JSON.stringify(value)); // return as json array + } + } let res = super.escape.bind(this)(value, unquoted); if (typeof value === 'string') { if (REGEXP_SINGLE_QUOTE.test(res)) @@ -279,7 +293,7 @@ class SqliteFormatter extends SqlFormatter { * @param {*} expr * @return {string} */ - $jsonArray(expr) { + $jsonEach(expr) { return `json_each(${this.escapeName(expr)})`; } @@ -365,6 +379,68 @@ class SqliteFormatter extends SqlFormatter { }, []); return `json_object(${args.join(',')})`;; } + + /** + * @param {{ $jsonGet: Array<*> }} expr + */ + $jsonGroupArray(expr) { + const [key] = Object.keys(expr); + if (key !== '$jsonObject') { + throw new Error('Invalid json group array expression. Expected a json object expression'); + } + return `json_group_array(${this.escape(expr)})`; + } + + /** + * @param {import('@themost/query').QueryExpression} expr + */ + $jsonArray(expr) { + if (expr == null) { + throw new Error('The given query expression cannot be null'); + } + if (expr instanceof QueryField) { + // escape expr as field and waiting for parsing results as json array + return this.escape(expr); + } + // trear expr as select expression + if (expr.$select) { + // get select fields + const args = Object.keys(expr.$select).reduce((previous, key) => { + previous.push.apply(previous, expr.$select[key]); + return previous; + }, []); + const [key] = Object.keys(expr.$select); + // prepare select expression to return json array + expr.$select[key] = [ + { + $jsonGroupArray: [ // use json_group_array function + { + $jsonObject: args // use json_object function + } + ] + } + ]; + return `(${this.format(expr)})`; + } + // treat expression as query field + if (Object.prototype.hasOwnProperty.call(expr, '$name')) { + return this.escape(expr); + } + // treat expression as value + if (Object.prototype.hasOwnProperty.call(expr, '$value')) { + if (Array.isArray(expr.$value)) { + return this.escape(JSON.stringify(expr.$value)); + } + return this.escape(expr); + } + if (Object.prototype.hasOwnProperty.call(expr, '$literal')) { + if (Array.isArray(expr.$literal)) { + return this.escape(JSON.stringify(expr.$literal)); + } + return this.escape(expr); + } + throw new Error('Invalid json array expression. Expected a valid select expression'); + } } export { diff --git a/src/isObjectDeep.js b/src/isObjectDeep.js new file mode 100644 index 0000000..da628f1 --- /dev/null +++ b/src/isObjectDeep.js @@ -0,0 +1,41 @@ +import isPlainObject from 'lodash/isPlainObject'; +import isObjectLike from 'lodash/isObjectLike'; +import isNative from 'lodash/isNative'; + +const objectToString = Function.prototype.toString.call(Object); + +function isObjectDeep(any) { + // check if it is a plain object + let result = isPlainObject(any); + if (result) { + return result; + } + // check if it's object + if (isObjectLike(any) === false) { + return false; + } + // get prototype + let proto = Object.getPrototypeOf(any); + // if prototype exists, try to validate prototype recursively + while(proto != null) { + // get constructor + const Ctor = Object.prototype.hasOwnProperty.call(proto, 'constructor') + && proto.constructor; + // check if constructor is native object constructor + result = (typeof Ctor == 'function') && (Ctor instanceof Ctor) + && Function.prototype.toString.call(Ctor) === objectToString; + // if constructor is not object constructor and belongs to a native class + if (result === false && isNative(Ctor) === true) { + // return false + return false; + } + // otherwise. get parent prototype and continue + proto = Object.getPrototypeOf(proto); + } + // finally, return result + return result; +} + +export { + isObjectDeep +} \ No newline at end of file