Skip to content

Commit

Permalink
implement $jsonArray() dialect (#108)
Browse files Browse the repository at this point in the history
* implement $jsonArray() dialect

* use jsonArray() for selecting values

* validate $jsonArray dialect

* serialize array of objects

* update tests

* 2.9.2
  • Loading branch information
kbarbounakis authored Feb 9, 2025
1 parent c918edf commit ec4b7c2
Show file tree
Hide file tree
Showing 8 changed files with 246 additions and 8 deletions.
8 changes: 5 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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"
Expand Down
115 changes: 113 additions & 2 deletions spec/QueryExpression.selectJson.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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;
Expand All @@ -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,
Expand All @@ -82,6 +98,7 @@ async function createSimpleOrders(db) {
orderStatus,
orderedItem,
paymentMethod,
additionalPaymentMethods,
customer,
dateCreated,
dateModified,
Expand Down Expand Up @@ -601,4 +618,98 @@ describe('SqlFormatter', () => {
}
});


it('should return json arrays', async () => {
// set context user
context.user = {
name: '[email protected]'
};

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: '[email protected]'
};
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: '[email protected]'
};
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' ]);
});
});
7 changes: 7 additions & 0 deletions spec/config/models/SimpleOrder.json
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,13 @@
"type": "Integer",
"calculation": "javascript:return this.user();",
"readonly": true
},
{
"name": "additionalPaymentMethods",
"type": "Json",
"additionalType": "PaymentMethod",
"expandable": true,
"many": true
}
],
"views": [
Expand Down
Binary file modified spec/db/local.db
Binary file not shown.
2 changes: 1 addition & 1 deletion src/SqliteAdapter.js
Original file line number Diff line number Diff line change
Expand Up @@ -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];
});
Expand Down
78 changes: 77 additions & 1 deletion src/SqliteFormatter.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -279,7 +293,7 @@ class SqliteFormatter extends SqlFormatter {
* @param {*} expr
* @return {string}
*/
$jsonArray(expr) {
$jsonEach(expr) {
return `json_each(${this.escapeName(expr)})`;
}

Expand Down Expand Up @@ -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 {
Expand Down
41 changes: 41 additions & 0 deletions src/isObjectDeep.js
Original file line number Diff line number Diff line change
@@ -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
}

0 comments on commit ec4b7c2

Please sign in to comment.