Skip to content

Commit ec4b7c2

Browse files
authored
implement $jsonArray() dialect (#108)
* implement $jsonArray() dialect * use jsonArray() for selecting values * validate $jsonArray dialect * serialize array of objects * update tests * 2.9.2
1 parent c918edf commit ec4b7c2

8 files changed

Lines changed: 246 additions & 8 deletions

File tree

package-lock.json

Lines changed: 5 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@themost/sqlite",
3-
"version": "2.9.1",
3+
"version": "2.9.2",
44
"description": "MOST Web Framework SQLite Adapter",
55
"main": "dist/index.js",
66
"types": "dist/index.d.ts",
@@ -30,6 +30,7 @@
3030
"dependencies": {
3131
"@themost/events": "^1.5.0",
3232
"async": "^2.6.4",
33+
"lodash": "^4.17.21",
3334
"sprintf-js": "^1.1.2",
3435
"sqlite3": "^5.1.7",
3536
"unzipper": "^0.12.3"

spec/QueryExpression.selectJson.spec.js

Lines changed: 113 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@ async function createSimpleOrders(db) {
1717
const { source } = SimpleOrderSchema;
1818
const exists = await db.table(source).existsAsync();
1919
if (!exists) {
20-
await db.table(source).createAsync(SimpleOrderSchema.fields);
20+
await db.table(source).createAsync(SimpleOrderSchema.fields);
21+
} else {
22+
return;
2123
}
2224
// get some orders
2325
const orders = await db.executeAsync(
@@ -61,7 +63,19 @@ async function createSimpleOrders(db) {
6163
return {id, streetAddress, postalCode, addressLocality, addressCountry, telephone };
6264
}), []
6365
);
64-
// get
66+
67+
const shuffleArray = (array) => {
68+
for (let i = array.length - 1; i > 0; i--) {
69+
const j = Math.floor(Math.random() * (i + 1));
70+
[array[i], array[j]] = [array[j], array[i]];
71+
}
72+
return array;
73+
};
74+
75+
const getRandomItems = (array, numItems) => {
76+
const shuffledArray = shuffleArray([...array]);
77+
return shuffledArray.slice(0, numItems);
78+
};
6579
const items = orders.map((order) => {
6680
const { orderDate, discount, discountCode, orderNumber, paymentDue,
6781
dateCreated, dateModified, createdBy, modifiedBy } = order;
@@ -73,6 +87,8 @@ async function createSimpleOrders(db) {
7387
customer.address = postalAddresses.find((x) => x.id === customer.address);
7488
delete customer.address?.id;
7589
}
90+
// get 2 random payment methods
91+
const additionalPaymentMethods = getRandomItems(paymentMethods, 2);
7692
return {
7793
orderDate,
7894
discount,
@@ -82,6 +98,7 @@ async function createSimpleOrders(db) {
8298
orderStatus,
8399
orderedItem,
84100
paymentMethod,
101+
additionalPaymentMethods,
85102
customer,
86103
dateCreated,
87104
dateModified,
@@ -601,4 +618,98 @@ describe('SqlFormatter', () => {
601618
}
602619
});
603620

621+
622+
it('should return json arrays', async () => {
623+
// set context user
624+
context.user = {
625+
name: 'alexis.rees@example.com'
626+
};
627+
628+
const queryPeople = context.model('Person').asQueryable().select(
629+
'id', 'familyName', 'givenName', 'jobTitle', 'email'
630+
).flatten();
631+
await beforeExecuteAsync({
632+
model: queryPeople.model,
633+
emitter: queryPeople,
634+
query: queryPeople.query,
635+
});
636+
const { viewAdapter: People } = queryPeople.model;
637+
const queryOrders = context.model('Order').asQueryable().select(
638+
'id', 'orderDate', 'orderStatus', 'orderedItem', 'customer'
639+
).flatten();
640+
const { viewAdapter: Orders } = queryOrders.model;
641+
// prepare query for each customer
642+
queryOrders.query.where(
643+
new QueryField('customer').from(Orders)
644+
).equal(
645+
new QueryField('id').from(People)
646+
);
647+
const selectPeople = queryPeople.query.$select[People];
648+
// add orders as json array
649+
selectPeople.push({
650+
orders: {
651+
$jsonArray: [
652+
queryOrders.query
653+
]
654+
}
655+
});
656+
const start= new Date().getTime();
657+
const items = await queryPeople.take(50).getItems();
658+
const end = new Date().getTime();
659+
TraceUtils.log('Elapsed time: ' + (end-start) + 'ms');
660+
expect(items.length).toBeTruthy();
661+
for (const item of items) {
662+
expect(Array.isArray(item.orders)).toBeTruthy();
663+
for (const order of item.orders) {
664+
expect(order.customer).toEqual(item.id);
665+
}
666+
667+
}
668+
});
669+
670+
it('should parse string as json array', async () => {
671+
// set context user
672+
context.user = {
673+
name: 'alexis.rees@example.com'
674+
};
675+
const { viewAdapter: People } = context.model('Person');
676+
const query = new QueryExpression().select(
677+
'id', 'familyName', 'givenName', 'jobTitle', 'email',
678+
new QueryField({
679+
tags: {
680+
$jsonArray: [
681+
new QueryField({
682+
$value: '[ "user", "customer", "admin" ]'
683+
})
684+
]
685+
}
686+
})
687+
).from(People).where('email').equal(context.user.name);
688+
const [item] = await context.db.executeAsync(query);
689+
expect(item).toBeTruthy();
690+
});
691+
692+
it('should parse array as json array', async () => {
693+
// set context user
694+
context.user = {
695+
name: 'alexis.rees@example.com'
696+
};
697+
const { viewAdapter: People } = context.model('Person');
698+
const query = new QueryExpression().select(
699+
'id', 'familyName', 'givenName', 'jobTitle', 'email',
700+
new QueryField({
701+
tags: {
702+
$jsonArray: [
703+
{
704+
$value: [ 'user', 'customer', 'admin' ]
705+
}
706+
]
707+
}
708+
})
709+
).from(People).where('email').equal(context.user.name);
710+
const [item] = await context.db.executeAsync(query);
711+
expect(item).toBeTruthy();
712+
expect(Array.isArray(item.tags)).toBeTruthy();
713+
expect(item.tags).toEqual([ 'user', 'customer', 'admin' ]);
714+
});
604715
});

spec/config/models/SimpleOrder.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,13 @@
172172
"type": "Integer",
173173
"calculation": "javascript:return this.user();",
174174
"readonly": true
175+
},
176+
{
177+
"name": "additionalPaymentMethods",
178+
"type": "Json",
179+
"additionalType": "PaymentMethod",
180+
"expandable": true,
181+
"many": true
175182
}
176183
],
177184
"views": [

spec/db/local.db

0 Bytes
Binary file not shown.

src/SqliteAdapter.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ function onReceivingJsonObject(event) {
4444
if (typeof key !== 'string') {
4545
return false;
4646
}
47-
return x[key].$jsonObject != null || x[key].$json != null;
47+
return x[key].$jsonObject != null || x[key].$jsonGroupArray != null || x[key].$jsonArray != null;
4848
}).map((x) => {
4949
return Object.keys(x)[0];
5050
});

src/SqliteFormatter.js

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { sprintf } from 'sprintf-js';
44
import { SqlFormatter, QueryField } from '@themost/query';
5+
import { isObjectDeep } from './isObjectDeep';
56
const REGEXP_SINGLE_QUOTE=/\\'/g;
67
const SINGLE_QUOTE_ESCAPE ='\'\'';
78
const REGEXP_DOUBLE_QUOTE=/\\"/g;
@@ -52,6 +53,19 @@ class SqliteFormatter extends SqlFormatter {
5253
if (value instanceof Date) {
5354
return this.escapeDate(value);
5455
}
56+
// serialize array of objects as json array
57+
if (Array.isArray(value)) {
58+
// find first non-object value
59+
const index = value.filter((x) => {
60+
return x != null;
61+
}).findIndex((x) => {
62+
return isObjectDeep(x) === false;
63+
});
64+
// if all values are objects
65+
if (index === -1) {
66+
return this.escape(JSON.stringify(value)); // return as json array
67+
}
68+
}
5569
let res = super.escape.bind(this)(value, unquoted);
5670
if (typeof value === 'string') {
5771
if (REGEXP_SINGLE_QUOTE.test(res))
@@ -279,7 +293,7 @@ class SqliteFormatter extends SqlFormatter {
279293
* @param {*} expr
280294
* @return {string}
281295
*/
282-
$jsonArray(expr) {
296+
$jsonEach(expr) {
283297
return `json_each(${this.escapeName(expr)})`;
284298
}
285299

@@ -365,6 +379,68 @@ class SqliteFormatter extends SqlFormatter {
365379
}, []);
366380
return `json_object(${args.join(',')})`;;
367381
}
382+
383+
/**
384+
* @param {{ $jsonGet: Array<*> }} expr
385+
*/
386+
$jsonGroupArray(expr) {
387+
const [key] = Object.keys(expr);
388+
if (key !== '$jsonObject') {
389+
throw new Error('Invalid json group array expression. Expected a json object expression');
390+
}
391+
return `json_group_array(${this.escape(expr)})`;
392+
}
393+
394+
/**
395+
* @param {import('@themost/query').QueryExpression} expr
396+
*/
397+
$jsonArray(expr) {
398+
if (expr == null) {
399+
throw new Error('The given query expression cannot be null');
400+
}
401+
if (expr instanceof QueryField) {
402+
// escape expr as field and waiting for parsing results as json array
403+
return this.escape(expr);
404+
}
405+
// trear expr as select expression
406+
if (expr.$select) {
407+
// get select fields
408+
const args = Object.keys(expr.$select).reduce((previous, key) => {
409+
previous.push.apply(previous, expr.$select[key]);
410+
return previous;
411+
}, []);
412+
const [key] = Object.keys(expr.$select);
413+
// prepare select expression to return json array
414+
expr.$select[key] = [
415+
{
416+
$jsonGroupArray: [ // use json_group_array function
417+
{
418+
$jsonObject: args // use json_object function
419+
}
420+
]
421+
}
422+
];
423+
return `(${this.format(expr)})`;
424+
}
425+
// treat expression as query field
426+
if (Object.prototype.hasOwnProperty.call(expr, '$name')) {
427+
return this.escape(expr);
428+
}
429+
// treat expression as value
430+
if (Object.prototype.hasOwnProperty.call(expr, '$value')) {
431+
if (Array.isArray(expr.$value)) {
432+
return this.escape(JSON.stringify(expr.$value));
433+
}
434+
return this.escape(expr);
435+
}
436+
if (Object.prototype.hasOwnProperty.call(expr, '$literal')) {
437+
if (Array.isArray(expr.$literal)) {
438+
return this.escape(JSON.stringify(expr.$literal));
439+
}
440+
return this.escape(expr);
441+
}
442+
throw new Error('Invalid json array expression. Expected a valid select expression');
443+
}
368444
}
369445

370446
export {

src/isObjectDeep.js

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import isPlainObject from 'lodash/isPlainObject';
2+
import isObjectLike from 'lodash/isObjectLike';
3+
import isNative from 'lodash/isNative';
4+
5+
const objectToString = Function.prototype.toString.call(Object);
6+
7+
function isObjectDeep(any) {
8+
// check if it is a plain object
9+
let result = isPlainObject(any);
10+
if (result) {
11+
return result;
12+
}
13+
// check if it's object
14+
if (isObjectLike(any) === false) {
15+
return false;
16+
}
17+
// get prototype
18+
let proto = Object.getPrototypeOf(any);
19+
// if prototype exists, try to validate prototype recursively
20+
while(proto != null) {
21+
// get constructor
22+
const Ctor = Object.prototype.hasOwnProperty.call(proto, 'constructor')
23+
&& proto.constructor;
24+
// check if constructor is native object constructor
25+
result = (typeof Ctor == 'function') && (Ctor instanceof Ctor)
26+
&& Function.prototype.toString.call(Ctor) === objectToString;
27+
// if constructor is not object constructor and belongs to a native class
28+
if (result === false && isNative(Ctor) === true) {
29+
// return false
30+
return false;
31+
}
32+
// otherwise. get parent prototype and continue
33+
proto = Object.getPrototypeOf(proto);
34+
}
35+
// finally, return result
36+
return result;
37+
}
38+
39+
export {
40+
isObjectDeep
41+
}

0 commit comments

Comments
 (0)