diff --git a/package-lock.json b/package-lock.json index fcd8e75..ed29cd7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@themost/query", - "version": "2.13.1", + "version": "2.14.0", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/package.json b/package.json index a14a7f9..3d31108 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@themost/query", - "version": "2.13.1", + "version": "2.14.0", "description": "MOST Web Framework Codename ZeroGravity - Query Module", "main": "dist/index.cjs.js", "module": "dist/index.esm.js", diff --git a/spec/QueryExpression.selectJson.spec.js b/spec/QueryExpression.selectJson.spec.js new file mode 100644 index 0000000..6125588 --- /dev/null +++ b/spec/QueryExpression.selectJson.spec.js @@ -0,0 +1,276 @@ +// noinspection SpellCheckingInspection + +import {MemberExpression, MethodCallExpression} from '../src/index'; +import { QueryEntity, QueryExpression } from '../src/index'; +import { SqliteFormatter } from '@themost/sqlite'; +import { MemoryAdapter } from './test/TestMemoryAdapter'; +import { MemoryFormatter } from './test/TestMemoryFormatter'; +import SimpleOrderSchema from './test/config/models/SimpleOrder.json'; + +if (typeof SqliteFormatter.prototype.$jsonGet !== 'function') { + SqliteFormatter.prototype.$jsonGet = function(expr) { + if (typeof expr.$name !== 'string') { + throw new Error('Invalid json expression. Expected a string'); + } + const parts = expr.$name.split('.'); + const extract = this.escapeName(parts.splice(0, 2).join('.')); + return `json_extract(${extract}, '$.${parts.join('.')}')`; + }; + SqliteFormatter.prototype.$jsonArray = function(expr) { + return `json_each(${this.escapeName(expr)})`; + } + // const superEscape = SqlUtils.escape; + // SqlUtils.escape = function(value) { + // if (isObjectDeep(value)) { + // return `'${JSON.stringify(value)}'`; + // } else { + // const args = Array.from(arguments) + // return superEscape.apply(null, args); + // } + // } +} + +/** + * @param { MemoryAdapter } db + * @returns {Promise} + */ +async function createSimpleOrders(db) { + const { source } = SimpleOrderSchema; + const exists = await db.table(source).existsAsync(); + if (!exists) { + await db.table(source).createAsync(SimpleOrderSchema.fields); + } + // get some orders + const orders = await db.executeAsync( + new QueryExpression().from('OrderBase').select( + ({orderDate, discount, discountCode, orderNumber, paymentDue, + dateCreated, dateModified, createdBy, modifiedBy, + orderStatus, orderedItem, paymentMethod, customer}) => { + return { orderDate, discount, discountCode, orderNumber, paymentDue, + dateCreated, dateModified, createdBy, modifiedBy, + orderStatus, orderedItem, paymentMethod, customer}; + }) + .orderByDescending((x) => x.orderDate).take(10), [] + ); + const paymentMethods = await db.executeAsync( + new QueryExpression().from('PaymentMethodBase').select( + ({id, name, alternateName, description}) => { + return { id, name, alternateName, description }; + }), [] + ); + const orderStatusTypes = await db.executeAsync( + new QueryExpression().from('OrderStatusTypeBase').select( + ({id, name, alternateName, description}) => { + return { id, name, alternateName, description }; + }), [] + ); + const orderedItems = await db.executeAsync( + new QueryExpression().from('ProductData').select( + ({id, name, category, model, releaseDate, price}) => { + return { id, name, category, model, releaseDate, price }; + }), [] + ); + const customers = await db.executeAsync( + new QueryExpression().from('PersonData').select( + ({id, familyName, givenName, jobTitle, email, description, address}) => { + return { id, familyName, givenName, jobTitle, email, description, address }; + }), [] + ); + const postalAddresses = await db.executeAsync( + new QueryExpression().from('PostalAddressData').select( + ({id, streetAddress, postalCode, addressLocality, addressCountry, telephone}) => { + return {id, streetAddress, postalCode, addressLocality, addressCountry, telephone }; + }), [] + ); + // get + const items = orders.map((order) => { + const { orderDate, discount, discountCode, orderNumber, paymentDue, + dateCreated, dateModified, createdBy, modifiedBy } = order; + const orderStatus = orderStatusTypes.find((x) => x.id === order.orderStatus); + const orderedItem = orderedItems.find((x) => x.id === order.orderedItem); + const paymentMethod = paymentMethods.find((x) => x.id === order.paymentMethod); + const customer = customers.find((x) => x.id === order.customer); + if (customer) { + customer.address = postalAddresses.find((x) => x.id === customer.address); + delete customer.address?.id; + } + return { + orderDate, + discount, + discountCode, + orderNumber, + paymentDue, + orderStatus, + orderedItem, + paymentMethod, + customer, + dateCreated, + dateModified, + createdBy, + modifiedBy + } + }); + for (const item of items) { + await db.executeAsync(new QueryExpression().insert(item).into(source), []); + } +} + +function onResolvingJsonMember(event) { + let member = event.fullyQualifiedMember.split('.'); + const field = SimpleOrderSchema.fields.find((x) => x.name === member[0]); + if (field == null) { + return; + } + if (field.type !== 'Json') { + return; + } + event.object = event.target.$collection; + event.member = new MethodCallExpression('jsonGet', [ + new MemberExpression(event.target.$collection + '.' + event.fullyQualifiedMember) + ]); +} + +describe('SqlFormatter', () => { + + /** + * @type {MemoryAdapter} + */ + let db; + beforeAll((done) => { + MemoryAdapter.create({ + name: 'local', + database: './spec/db/local.db' + }).then((adapter) => { + db = adapter; + return done(); + }).catch((err) => { + return done(err); + }); + }); + afterAll((done) => { + if (db) { + db.close(() => { + MemoryAdapter.drop(db).then(() => { + return done(); + }); + }); + } + }); + + it('should select json field', async () => { + await createSimpleOrders(db); + const Orders = new QueryEntity('SimpleOrders'); + const query = new QueryExpression(); + query.resolvingJoinMember.subscribe(onResolvingJsonMember); + query.select((x) => { + // noinspection JSUnresolvedReference + return { + id: x.id, + customer: x.customer.description + } + }) + .from(Orders); + const formatter = new MemoryFormatter(); + const sql = formatter.format(query); + expect(sql).toEqual('SELECT `SimpleOrders`.`id` AS `id`, json_extract(`SimpleOrders`.`customer`, \'$.description\') AS `customer` FROM `SimpleOrders`'); + /** + * @type {Array<{id: number, customer: string}>} + */ + const results = await db.executeAsync(sql, []); + expect(results).toBeTruthy(); + for (const result of results) { + expect(result).toBeTruthy(); + expect(result.id).toBeTruthy(); + expect(result.customer).toBeTruthy(); + } + }); + + it('should select nested json field', async () => { + await createSimpleOrders(db); + const Orders = new QueryEntity('SimpleOrders'); + const query = new QueryExpression(); + query.resolvingJoinMember.subscribe(onResolvingJsonMember); + query.select((x) => { + // noinspection JSUnresolvedReference + return { + id: x.id, + customer: x.customer.description, + address: x.customer.address.streetAddress + } + }) + .from(Orders); + const formatter = new MemoryFormatter(); + const sql = formatter.format(query); + expect(sql).toEqual('SELECT `SimpleOrders`.`id` AS `id`, ' + + 'json_extract(`SimpleOrders`.`customer`, \'$.description\') AS `customer`, ' + + 'json_extract(`SimpleOrders`.`customer`, \'$.address.streetAddress\') AS `address` ' + + 'FROM `SimpleOrders`'); + /** + * @type {Array<{id: number, customer: string}>} + */ + const results = await db.executeAsync(sql, []); + expect(results).toBeTruthy(); + for (const result of results) { + expect(result).toBeTruthy(); + expect(result.id).toBeTruthy(); + expect(result.customer).toBeTruthy(); + } + }); + + it('should select nested json field with method', async () => { + await createSimpleOrders(db); + const Orders = new QueryEntity('SimpleOrders'); + const query = new QueryExpression(); + query.resolvingJoinMember.subscribe(onResolvingJsonMember); + query.select((x) => { + // noinspection JSUnresolvedReference + return { + id: x.id, + customer: x.customer.description, + releaseYear: x.orderedItem.releaseDate.getFullYear() + } + }) + .from(Orders); + const formatter = new MemoryFormatter(); + const sql = formatter.format(query); + /** + * @type {Array<{id: number, customer: string, releaseYear: number}>} + */ + const results = await db.executeAsync(sql, []); + expect(results).toBeTruthy(); + for (const result of results) { + expect(result).toBeTruthy(); + expect(result.releaseYear).toBeTruthy(); + } + }); + + it('should select json object', async () => { + await createSimpleOrders(db); + const Orders = new QueryEntity('SimpleOrders'); + const query = new QueryExpression(); + query.resolvingJoinMember.subscribe(onResolvingJsonMember); + query.select((x) => { + // noinspection JSUnresolvedReference + return { + id: x.id, + customer: x.customer, + orderedItem: x.orderedItem + } + }) + .from(Orders); + const formatter = new MemoryFormatter(); + const sql = formatter.format(query); + /** + * @type {Array<{id: number, customer: string, releaseYear: number}>} + */ + const results = await db.executeAsync(sql, []); + expect(results).toBeTruthy(); + for (const result of results) { + if (typeof result.customer === 'string') { + const customer = JSON.parse(result.customer); + expect(customer).toBeTruthy(); + } + } + }); + +}); diff --git a/spec/QueryExpression.string.spec.js b/spec/QueryExpression.string.spec.js index 1a30f44..e44ed89 100644 --- a/spec/QueryExpression.string.spec.js +++ b/spec/QueryExpression.string.spec.js @@ -129,7 +129,7 @@ describe('QueryExpression.where', () => { } }) .from(Products) - .where(({name: productName}) => { // use object destructuting with name + .where(({name: productName}) => { // use object destructuring with name return productName.indexOf('Intel') >= 0; }); results = await db.executeAsync(query); @@ -252,4 +252,4 @@ describe('QueryExpression.where', () => { expect(item.givenName.concat(' ', item.familyName)).toBe(item.name); }); }); -}); \ No newline at end of file +}); diff --git a/spec/is-object.js b/spec/is-object.js new file mode 100644 index 0000000..3d0bfdc --- /dev/null +++ b/spec/is-object.js @@ -0,0 +1,40 @@ +// MOST Web Framework 2.0 Codename Blueshift BSD-3-Clause license Copyright (c) 2017-2022, THEMOST LP All rights reserved +const {isPlainObject, isObjectLike, isNative} = require('lodash'); + +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; +} + +module.exports = { + isObjectDeep +} \ No newline at end of file diff --git a/spec/test/TestMemoryAdapter.js b/spec/test/TestMemoryAdapter.js index 1f9b292..985c84c 100644 --- a/spec/test/TestMemoryAdapter.js +++ b/spec/test/TestMemoryAdapter.js @@ -1,16 +1,60 @@ + import { SqliteAdapter } from '@themost/sqlite'; -import { copyFileSync } from 'fs'; -/** - * - */ -class MemoryAdapter extends SqliteAdapter { +import path from 'path'; +import os from 'os'; +import fs from 'fs'; - constructor() { - copyFileSync('./spec/db/local.db', './spec/db/test.db'); - super({ +class MemoryAdapter extends SqliteAdapter { + /** + * + * @param {*=} options + */ + constructor(options) { + super(options || { name: 'local', - database: './spec/db/test.db' + database: './spec/db/local.db' + }); + } + + /** + * @param {{ name: string, database: string }} options + * @returns {Promise} + */ + static create(options) { + const { name, database: source } = options; + const sourcePath = path.resolve(process.cwd(), source); + const { base } = path.parse(sourcePath); + const destPath = path.resolve(os.tmpdir(), base); + return new Promise((resolve, reject) => { + void fs.copyFile(sourcePath, destPath, (err) => { + if (err) { + return reject(err); + } + const test = true; + const database = destPath; + resolve(new MemoryAdapter({ + name, + test, + database + })); + }); + }); + } + + static drop(adapter) { + return new Promise((resolve, reject) => { + if (adapter?.options?.test) { + void fs.unlink(adapter.options.database, (err) => { + if (err) { + return reject(err); + } + resolve(); + }); + } else { + return resolve(); + } }); + } } diff --git a/spec/test/TestMemoryFormatter.js b/spec/test/TestMemoryFormatter.js index 3ffbcf5..f8c3a78 100644 --- a/spec/test/TestMemoryFormatter.js +++ b/spec/test/TestMemoryFormatter.js @@ -1,7 +1,14 @@ import { SqliteFormatter } from '@themost/sqlite'; +// noinspection JSUnusedGlobalSymbols +/** + * @augments {SqlFormatter} + */ class MemoryFormatter extends SqliteFormatter { + /** + * @constructor + */ constructor() { super(); } diff --git a/spec/test/config/models/SimpleOrder.json b/spec/test/config/models/SimpleOrder.json new file mode 100644 index 0000000..d67fdf6 --- /dev/null +++ b/spec/test/config/models/SimpleOrder.json @@ -0,0 +1,206 @@ +{ + "$schema": "https://themost-framework.github.io/themost/models/2018/2/schema.json", + "name": "SimpleOrder", + "title": "SimpleOrders", + "source": "SimpleOrders", + "hidden": false, + "sealed": false, + "abstract": false, + "version": "1.0.0", + "fields": [ + { + "@id": "https://themost.io/schemas/id", + "name": "id", + "title": "ID", + "description": "The identifier of the item.", + "type": "Counter", + "primary": true + }, + { + "name": "acceptedOffer", + "title": "Accepted Offer", + "description": "The offer e.g. product included in the order.", + "type": "Json", + "additionalType": "Offer" + }, + { + "name": "billingAddress", + "title": "Billing Address", + "description": "The billing address for the order.", + "type": "Json", + "additionalType": "PostalAddress" + }, + { + "name": "customer", + "title": "Customer", + "description": "Party placing the order.", + "type": "Json", + "additionalType": "Person", + "editable": false, + "nullable": false + }, + { + "name": "discount", + "title": "Discount", + "description": "Any discount applied (to an Order).", + "type": "Number" + }, + { + "name": "discountCode", + "title": "Discount Code", + "description": "Code used to redeem a discount.", + "type": "Text" + }, + { + "name": "discountCurrency", + "title": "Discount Currency", + "description": "The currency (in 3-letter ISO 4217 format) of the discount.", + "type": "Text" + }, + { + "name": "isGift", + "title": "Is Gift", + "description": "Was the offer accepted as a gift for someone other than the buyer.", + "type": "Boolean" + }, + { + "name": "merchant", + "title": "Merchant", + "description": "The party taking the order (e.g. Amazon.com is a merchant for many sellers).", + "type": "Json", + "additionalType": "Party" + }, + { + "name": "orderDate", + "title": "Order Date", + "description": "Date order was placed.", + "type": "DateTime", + "value": "javascript:return new Date();" + }, + { + "name": "orderedItem", + "title": "Ordered Item", + "description": "The item ordered.", + "type": "Json", + "additionalType": "Product", + "expandable": true, + "editable": true, + "nullable": false + }, + { + "name": "orderNumber", + "title": "Order Number", + "description": "The identifier of the transaction.", + "type": "Text", + "readonly": true, + "value": "javascript:return this.numbers(12);" + }, + { + "name": "orderStatus", + "title": "Order Status", + "description": "The current status of the order.", + "type": "Json", + "additionalType": "OrderStatusType", + "expandable": true, + "nullable": false, + "value": "javascript:return { alternateName: 'OrderProcessing' };" + }, + { + "name": "paymentDue", + "title": "Payment Due", + "description": "The date that payment is due.", + "type": "DateTime" + }, + { + "name": "paymentMethod", + "title": "Payment Method", + "description": "The name of the credit card or other method of payment for the order.", + "type": "Json", + "additionalType": "PaymentMethod", + "expandable": true + }, + { + "name": "paymentUrl", + "title": "Payment Url", + "description": "The URL for sending a payment.", + "type": "URL" + }, + { + "name": "additionalType", + "title": "Additional Type", + "description": "An additional type for the item, typically used for adding more specific types from external vocabularies in microdata syntax. This is a relationship between something and a class that the thing is in. In RDFa syntax, it is better to use the native RDFa syntax - the 'typeof' attribute - for multiple types. Schema.org tools may have only weaker understanding of extra types, in particular those defined externally.", + "type": "Text", + "readonly": true, + "value": "javascript:return this.model.name;" + }, + { + "name": "description", + "title": "Description", + "description": "A short description of the item.", + "type": "Text" + }, + { + "name": "dateCreated", + "title": "Date Created", + "description": "The date on which this item was created.", + "type": "Date", + "value": "javascript:return (new Date());", + "readonly": true + }, + { + "name": "dateModified", + "title": "Date Modified", + "description": "The date on which this item was most recently modified.", + "type": "Date", + "readonly": true, + "value": "javascript:return (new Date());", + "calculation": "javascript:return (new Date());" + }, + { + "name": "createdBy", + "title": "Created By", + "description": "Created by user.", + "type": "Integer", + "value": "javascript:return this.user();", + "readonly": true + }, + { + "name": "modifiedBy", + "title": "Modified By", + "description": "Modified by user.", + "type": "Integer", + "calculation": "javascript:return this.user();", + "readonly": true + } + ], + "views": [ + { + "name": "delivered", + "title": "Delivered Orders", + "filter": "orderStatus eq 1", + "order": "dateCreated desc" + }, + { + "name": "latest", + "title": "Latest Orders", + "filter": "orderDate gt lastMonth()", + "order": "dateCreated desc" + } + ], + "privileges": [ + { + "mask": 15, + "type": "global" + }, + { + "mask": 15, + "type": "global", + "account": "Administrators" + }, + { + "mask": 1, + "type": "self", + "filter": "customer/user eq me()" + } + ] +} diff --git a/src/expressions.js b/src/expressions.js index fe12035..d6392fb 100644 --- a/src/expressions.js +++ b/src/expressions.js @@ -1,7 +1,5 @@ // MOST Web Framework Codename Zero Gravity Copyright (c) 2017-2022, THEMOST LP All rights reserved -import { AbstractMethodError } from "@themost/common"; - /** * @abstract */ @@ -9,8 +7,12 @@ class Expression { constructor() { // } + + /** + * @returns {*} + */ exprOf() { - throw new AbstractMethodError(); + throw new Error('Class does not implement inherited abstract method.'); } } diff --git a/src/utils.js b/src/utils.js index 116d9fa..f94374a 100644 --- a/src/utils.js +++ b/src/utils.js @@ -128,7 +128,7 @@ function escape(val, stringifyObjects, timeZone) { if (stringifyObjects) { val = val.toString(); } else { - return objectToValues(val, timeZone); + return `'${JSON.stringify(val)}'`; } } val = val.replace(STR_ESCAPE_REGEXP, function(s) {