From ec4b7c236847059e8cbb830c6a0b3b9a40366119 Mon Sep 17 00:00:00 2001 From: Kyriakos Barbounakis Date: Sun, 9 Feb 2025 06:47:07 +0200 Subject: [PATCH] implement $jsonArray() dialect (#108) * implement $jsonArray() dialect * use jsonArray() for selecting values * validate $jsonArray dialect * serialize array of objects * update tests * 2.9.2 --- package-lock.json | 8 +- package.json | 3 +- spec/QueryExpression.selectJson.spec.js | 115 +++++++++++++++++++++++- spec/config/models/SimpleOrder.json | 7 ++ spec/db/local.db | Bin 2744320 -> 2744320 bytes src/SqliteAdapter.js | 2 +- src/SqliteFormatter.js | 78 +++++++++++++++- src/isObjectDeep.js | 41 +++++++++ 8 files changed, 246 insertions(+), 8 deletions(-) create mode 100644 src/isObjectDeep.js 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 9b4abf7d97aecbe85909daf90144fe4c42a5f10d..573c022280d438294dcfa572ec4311c838c962f2 100644 GIT binary patch delta 4976 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 K_-)Zw{=Wmz3QWcT 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