diff --git a/.eslintrc b/.eslintrc index c0e0751..3a85d71 100644 --- a/.eslintrc +++ b/.eslintrc @@ -4,7 +4,7 @@ "plugin:node/recommended" ], "parserOptions": { - "ecmaVersion": 2015, + "ecmaVersion": 2018, "sourceType": "module" }, "env": { diff --git a/ValueFormatter.d.ts b/ValueFormatter.d.ts new file mode 100644 index 0000000..e77fdf4 --- /dev/null +++ b/ValueFormatter.d.ts @@ -0,0 +1,44 @@ +import { DataModelBase } from "@themost/common"; +import { AsyncSeriesEventEmitter } from '@themost/events'; +import { DataContext } from "./types"; +import { DataModel } from './data-model'; + +export declare interface ResolvingVariable { + name: string; + model?: DataModelBase; + context?: DataContext; + target?: any; + value?: any; +} + + +/** + * Class responsible for formatting values within a given data context. + */ +export declare class ValueFormatter { + + readonly resolvingVariable: AsyncSeriesEventEmitter; + /** + * Creates an instance of ValueFormatter. + * @param context - The data context in which the formatter operates. + */ + constructor(context: DataContext, model?: DataModel, target?: any); + + /** + * Formats the given value according to the rules defined in the data context. + * @param value - The value to be formatted. + * @returns The formatted value. + */ + format(value: unknown): any; + + static register(name: string, definition: { [k: string]: (...value: any[]) => Promise }): void; +} + +export declare class ValueDialect { + /** + * Creates an instance of ValueDialect. + * @param context - The data context in which the dialect operates. + */ + constructor(context: DataContext, model: DataModel, target: any); + +} diff --git a/ValueFormatter.js b/ValueFormatter.js new file mode 100644 index 0000000..855048f --- /dev/null +++ b/ValueFormatter.js @@ -0,0 +1,771 @@ +const { Args, Guid, DataError } = require('@themost/common'); +const moment = require('moment'); +const { v4 } = require('uuid'); +const {isObjectDeep} = require('./is-object'); +const random = require('lodash/random'); +const getProperty = require('lodash/get'); +const esprima = require('esprima'); +require('@themost/promise-sequence'); +const { AsyncSeriesEventEmitter } = require('@themost/events'); +const { round } = require('@themost/query'); +const MD5 = require('crypto-js/md5'); +const { DataAttributeResolver } = require('./data-attribute-resolver'); + +const testFieldRegex = /^\$\w+(\.\w+)*$/g; + +/** + * @param {ValueFormatter} formatter + * @param {import('./data-model').DataModel} model + * @param {import('./data-queryable').DataQueryable} emitter + * @returns + */ +function getValueReplacer(formatter, model, emitter) { + return function(key, value) { + if (typeof value === 'string') { + if (/^\$\$/.test(value)) { + return formatter.formatVariableSync(value); + } + if (testFieldRegex.test(value)) { + const name = value.replace(/^\$/, ''); + const parts = name.split('.'); + if (parts.length === 1) { + const { viewAdapter: collection } = model; + const field = model.getAttribute(name); + if (field) { + return { + $name: collection + '.' + name + } + } + throw new DataError('An expression contains an attribute that cannot be found', null, model.name, name); + } else { + const result = new DataAttributeResolver().resolveNestedAttribute.bind(emitter)(name.replace(/\./g, '/')); + if (result) { + return result; + } + throw new DataError('An nested expression contains an attribute that cannot be found', null, model.name, name); + } + } + } + return value; + } +} + + +function getFunctionArguments(fn) { + if (typeof fn !== 'function') { + throw new Error('Invalid parameter. Expected function.'); + } + // if dialect function params are already cached + if (Object.prototype.hasOwnProperty.call(fn, 'dialectFunctionParams')) { + // return cached function params + return fn.dialectFunctionParams; + } + const fnString = fn.toString().trim(); + let ast; + if (/^function\s+/i.test(fnString) === false) { + if (/^async\s+/i.test(fnString)) { + ast = esprima.parseScript(fnString.replace(/^async\s+/, 'async function ')); + } else { + ast = esprima.parseScript('function ' + fnString); + } + } else { + ast = esprima.parseScript(fnString); + } + const params = ast.body[0].params.map((param) => param.name); + // cache function params + Object.defineProperty(fn, 'dialectFunctionParams', { + configurable: true, + enumerable: false, + writable: true, + value: params + }); + return params; +} + +class ValueDialect { + /** + * @param {import('./types').DataContext} context + * @param {*} target + */ + constructor(context, model, target) { + this.context = context; + this.model = model; + this.target = target; + } + + /** + * Get the current date and time + * @returns {Promise} + */ + async $date() { + return new Date(); + } + + /** + * Add the specified amount of time to the specified date + * @returns {Promise} + */ + async $dateAdd(startDate, unit, amount) { + return moment(startDate).add(amount, unit).toDate(); + } + + /** + * Add the specified amount of time to the specified date + * @returns {Promise} + */ + async $dateSubtract(startDate, unit, amount) { + return moment(startDate).subtract(amount, unit).toDate(); + } + + /** + * A shorthand for $date method + * @returns {Promise} + */ + async $now() { + return new Date(); + } + + /** + * Get the current date + * @returns {Promise} + */ + async $today() { + return moment(new Date()).startOf('day').toDate(); + } + + async $year(date) { + return moment(date).year(); + } + + async $month(date) { + return moment(date).month(); + } + + async $dayOfMonth(date) { + return moment(date).date(); + } + + async $dayOfWeek(date) { + return moment(date).day(); + } + + async $hour(date) { + return moment(date).hour(); + } + + async $minutes(date) { + return moment(date).minutes(); + } + + async $seconds(date) { + return moment(date).seconds(); + } + + /** + * Get current user identifier or the value of the specified attribute + * @param {string=} property + * @returns Promise + */ + $user(property) { + const selectAttribute = property || 'id'; + let name = this.context.user && this.context.user.name; + if (Object.prototype.hasOwnProperty.call(this.context, 'interactiveUser') === true) { + name = this.context.interactiveUser && this.context.interactiveUser.name; + } + return this.context.model('User').asQueryable().where((x, username) => { + return x.name === username && x.name != null && x.name != 'anonymous'; + }, name).select(selectAttribute).value().then((result) => { + if (typeof result === 'undefined') { + return null; + } + return result; + }); + } + /** + * A shorthand for $user method + * @param {string=} property + * @returns Promise + */ + $me(property) { + return this.$user(property); + } + + /** + * Get a new GUID value + * @returns {Promise} + */ + $newGuid() { + return Promise.resolve(v4().toString()); + } + + /** + * Get a new GUID value + * @returns {Promise} + */ + $uuid() { + return Promise.resolve(v4().toString()); + } + + /** + * Get a new identifier value for the current data model + * @returns {Promise} + */ + $newid() { + return new Promise((resolve, reject) => { + this.model.context.db.selectIdentity(this.model.sourceAdapter, this.model.primaryKey, (err, result) => { + if (err) { + return reject(err); + } + resolve(result); + }); + }); + } + + /** + * @param {...*} args + */ + $concat() { + return Promise.resolve(Array.from(arguments).join('')); + } + + /** + * @param {*} value + */ + $toLowerCase(value) { + return Promise.resolve(value == null ? null : String(value).toLowerCase()); + } + + $toLower(value) { + return this.$toLowerCase(value); + } + + /** + * @param {*} value + */ + $toUpperCase(value) { + return Promise.resolve(value == null ? null : String(value).toUpperCase()); + } + + $toUpper(value) { + return this.$toUpperCase(value); + } + + $length(value) { + return value == null ? 0 : value.length; + } + + $substring(value, start, length) { + return value == null ? null : value.substring(start, length); + } + + /** + * Returns a random string with the specified length + * @param {number=} length + * @returns + */ + $randomString(length) { + return new Promise((resolve, reject) => { + try { + length = length || 8; + var chars = 'abcdefghkmnopqursuvwxz2456789ABCDEFHJKLMNPQURSTUVWXYZ'; + var str = ''; + var rnd; + for(var i = 0; i < length; i++) { + rnd = random(0, chars.length - 1); + str += chars.substring(rnd, rnd + 1); + } + resolve(str); + } + catch (err) { + reject(err); + } + }); + } + + /** + * A shorthand for $randomString method + * @param {number=} length + * @returns {Promise} + */ + $chars(length) { + return this.$randomString(length); + } + + /** + * Returns a random integer value + * @param {number=} min + * @param {number=} max + * @returns + */ + $randomInt(min, max) { + return new Promise((resolve, reject) =>{ + try { + resolve(random(min, max)); + } + catch (err) { + reject(err); + } + }); + } + + /** + * + * @param {*} min + * @param {*} max + * @returns + */ + $int(min, max) { + return this.$randomInt(min, max); + } + + $abs(value) { + return value != null ? Math.abs(value) : null; + } + + /** + * Returns a random string containing only numbers with the specified length + * @param {number=} length + * @returns + */ + $randomNumbers(length) { + return new Promise((resolve, reject) => { + try { + length = length || 8; + var chars = '0123456789'; + var str = ''; + var rnd; + for(var i = 0; i < length; i++) { + rnd = random(0, chars.length - 1); + str += chars.substring(rnd, rnd + 1); + } + resolve(str); + } + catch (err) { + reject(err); + } + }); + } + + /** + * A shorthand for $randomNumbers method + * @param {number=} length + * @returns + */ + $numbers(length) { + return this.$randomNumbers(length); + } + + /** + * Returns a random password with the specified length + * @param {number} length + */ + $randomPassword(length) { + return new Promise((resolve, reject) => { + try { + length = length || 16; + const chars = 'abcdefghkmnopqursuvwxz2456789ABCDEFHJKLMNPQURTUVWXYZ'; + const numberChars = '0123456789'; + let requiredNumberChars = length < 8 ? 1 : 2; + const specialChars = '!@#$%^&*()_+'; + let requiredSpecialChars = length < 8 ? 1 : 2; + let str = ''; + let rnd; + for(var i = 0; i < length; i++) { + if (requiredNumberChars > 0 && random(0, 1) === 1) { + rnd = random(0, numberChars.length - 1); + str += numberChars.substring(rnd, rnd + 1); + requiredNumberChars--; + continue; + } + if (requiredSpecialChars > 0 && random(0, 1) === 1) { + rnd = random(0, specialChars.length - 1); + str += specialChars.substring(rnd, rnd + 1); + requiredSpecialChars--; + continue; + } + rnd = random(0, chars.length - 1); + str += chars.substring(rnd, rnd + 1); + } + resolve(str); + } + catch (err) { + reject(err); + } + }); + } + + /** + * A shorthand for $randomPassword method + * @param {number=} length + * @returns + */ + $password(length) { + return this.$randomPassword(length); + } + + /** + * Rounds the specified value to the nearest integer + * @param {*} value The value to round + * @param {*} place The number of decimal places to round + * @returns + */ + async $round(value, place) { + return round(value, place); + } + + /** + * Converts the specified value to a number and returns the absolute value + * @param {*} value + * @returns + */ + async $ceil(value) { + return Math.ceil(value); + } + + /** + * Converts the specified value to a number and returns the lowest integer value + * @param {*} value + * @returns + */ + async $floor(value) { + return Math.floor(value); + } + + async $add() { + return Array.from(arguments).reduce((a, b) => a + b, 0); + } + + async $subtract() { + return Array.from(arguments).reduce((a, b) => a - b, 0); + } + + async $multiply() { + return Array.from(arguments).reduce((a, b) => a * b, 1); + } + + async $divide() { + return Array.from(arguments).reduce((a, b) => a / b, 1); + } + + async $mod() { + return Array.from(arguments).reduce((a, b) => a % b, 0); + } + + async $toString(value) { + if (value == null) { + return null; + } + return String(value).toString(); + } + + async $trim(value) { + if (value == null) { + return null; + } + return String(value).trim(); + } + + async $toInt(value) { + return parseInt(value, 10); + } + + async $toDouble(value) { + if (typeof value === 'number') { + return Promise.resolve(value); + } + return parseFloat(value); + } + + async $toDecimal(value) { + if (typeof value === 'number') { + return Promise.resolve(value); + } + return parseFloat(value); + } + + /** + * Converts the specified value to a UUID + * @param {*} value + * @returns + */ + async $toGuid(value) { + if (Guid.isGuid(value)) { + return Promise.resolve(value); + } + var str = MD5(value).toString(); + return new Guid([ + str.substring(0, 8), + str.substring(8, 12), + str.substring(12, 16), + str.substring(16, 20), + str.substring(20, 32) + ].join('-')); + } + + /** + * A shorthand for $toGuid method + * @param {*} value + * @returns + */ + async $toUUID(value) { + return this.$toGuid(value); + } + + async $eq() { + const [a,b] = Array.from(arguments); + return a === b; + } + + async $gt() { + const [a,b] = Array.from(arguments); + return a > b; + } + + async $lt() { + const [a,b] = Array.from(arguments); + return a < b; + } + + async $gte() { + const [a,b] = Array.from(arguments); + return a >= b; + } + + async $lte() { + const [a,b] = Array.from(arguments); + return a <= b; + } + + async $ne() { + const [a,b] = Array.from(arguments); + return a !== b; + } + + async $or() { + return Array.from(arguments).reduce((a, b) => a || b, false); + } + + async $and() { + return Array.from(arguments).reduce((a, b) => a && b, true); + } + + async $cond(ifExpr, thenExpr, elseExpr) { + return ifExpr ? thenExpr : elseExpr; + } + + async $replaceOne(input, find, replacement) { + if (input == null) { + return null; + } + return input.replace(find, replacement); + } + + /** + * @param {string} input + * @param {string} find + * @param {string} replacement + */ + async $replaceAll(input, find, replacement) { + if (input == null) { + return null; + } + return input.replaceAll(new RegExp(find, 'g'), replacement); + } + +} + +class ValueFormatter { + + /** + * + * @param {import('./types').DataContext} context + * @param {import('@themost/common').DataModelBase=} model + * @param {*=} target + */ + constructor(context, model, target) { + this.context = context; + this.model = model; + this.target = target; + this.dialect = new ValueDialect(context, model, target); + this.resolvingVariable = new AsyncSeriesEventEmitter(); + } + + /** + * @param {string} value + * @returns Promise + */ + async formatVariable(value) { + const propertyPath = value.substring(2).split('.'); + const property = propertyPath.shift(); + if (Object.prototype.hasOwnProperty.call(this.dialect, property)) { + return getProperty(this.dialect[property], propertyPath.join('.')); + } else { + const event = { + name: value, + model: this.model, + context: this.context, + target: this.target + } + await this.resolvingVariable.emit('resolve', event); + if (Object.prototype.hasOwnProperty.call(event, 'value')) { + return event.value; + } + throw new Error(`Variable '${property}' not found.`); + } + } + + formatVariableSync(value) { + const propertyPath = value.substring(2).split('.'); + const property = propertyPath.shift(); + if (Object.prototype.hasOwnProperty.call(this.dialect, property)) { + return getProperty(this.dialect[property], propertyPath.join('.')); + } else { + throw new Error(`Variable '${property}' not found.`); + } + } + + /** + * + * @param {{$collection: string, $select: { value: string }, $where: *, $sort: *=, $order: Array<*>=, $group: Array<*>=}} query + */ + async formatQuery(query) { + const model = this.context.model(query.$collection); + const q = model.asQueryable(); + if (Object.prototype.hasOwnProperty.call(query, '$select') === false) { + throw new Error('Query expression $select statement not found.'); + } + if (Object.prototype.hasOwnProperty.call(query.$select, 'value') === false) { + throw new Error('Query expression $select statement should a value property.'); + } + // use select expression + // get value property + const { value: attribute } = query.$select; + // parse select expression + q.select(attribute.replace(/^\$/, '').replace(/^\./, '/')); + if (Object.prototype.hasOwnProperty.call(query, '$where') === false) { + throw new Error('Query expression $where statement not found.'); + } + /** + * @returns {function(string, *)} + */ + const nameReplacer = getValueReplacer(this, model, q); + const $where = JSON.parse(JSON.stringify(query.$where, function(key, value) { + return nameReplacer(key, value); + })); + Object.assign(q.query, { + $where + }); + if (Array.isArray(query.$order)) { + const $order = JSON.parse(JSON.stringify(query.$order, function(key, value) { + return nameReplacer(key, value); + })); + Object.assign(q.query, { + $order + }); + } + if (Array.isArray(query.$group)) { + const $group = JSON.parse(JSON.stringify(query.$group, function(key, value) { + return nameReplacer(key, value); + })); + Object.assign(q.query, { + $group + }); + } + return q.value(); + } + + /** + * @param {*} value + * @returns Promise + */ + format(value) { + if (isObjectDeep(value) === false) { + if (typeof value === 'string' && value.startsWith('$$')) { + return this.formatVariable(value); + } + return Promise.resolve(value); + } + // get property + const [property] = Object.keys(value); + // check if method is $value e.g. $value: 'Hello World' + if (property === '$value') { + const val = value[property]; + if (typeof val === 'string' && val.startsWith('$$')) { + return this.formatVariable(val); + } + return Promise.resolve(this.format(value)); + } + if (property.startsWith('$$')) { + return this.formatVariable(value); + } + // exception $cond method which is a special case of formatting method + if (property === '$cond') { + // use language keywords if, then, else + const cond = value[property]; + if (Object.prototype.hasOwnProperty.call(cond, 'if') && Object.prototype.hasOwnProperty.call(cond, 'then') && Object.prototype.hasOwnProperty.call(cond, 'else')) { + return Promise.all([ + this.format(cond.if), + this.format(cond.then), + this.format(cond.else) + ]).then(([ifExpr, thenExpr, elseExpr]) => { + return this.dialect.$cond(ifExpr, thenExpr, elseExpr); + }); + } + } + + if (property === '$query') { + return this.formatQuery(value[property]); + } + + // check if method exists + const propertyDescriptor = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(this.dialect), property); + if (propertyDescriptor) { + Args.check(propertyDescriptor.value instanceof Function, 'Dialect method must be a function.'); + // get arguments + const args = value[property]; + if (args == null) { + return Promise.resolve(null); + } + if (Array.isArray(args)) { + return Promise.sequence(args.map((arg) => { + return () => this.format(arg); + })).then((args) => { + // call dialect method + const invoke = propertyDescriptor.value; + return invoke.apply(this.dialect, args); + }); + } else { + const params = getFunctionArguments(propertyDescriptor.value); + return Promise.sequence(params.map((param) => { + if (Object.prototype.hasOwnProperty.call(args, param)) { + return args[param]; + } + return null; + }).map((arg) => { + return () => this.format(arg); + })).then((args) => { + // call dialect method + const invoke = propertyDescriptor.value; + return invoke.apply(this.dialect, args); + }); + } + + } else { + Promise.reject(new Error(`Dialect method '${property}' not found.`)); + } + } + + /** + * @param {*} name + * @param {*} definition + */ + static register(name, definition) { + Object.assign(ValueDialect.prototype, definition) + } + +} +//** @ts-ignore **/ +module.exports = { + ValueDialect, + ValueFormatter +}; \ No newline at end of file diff --git a/data-listeners.js b/data-listeners.js index b28846b..74254a2 100644 --- a/data-listeners.js +++ b/data-listeners.js @@ -13,6 +13,8 @@ var {TextUtils} = require('@themost/common'); var {DataCacheStrategy} = require('./data-cache'); var {DataFieldQueryResolver} = require('./data-field-query-resolver'); var {FunctionContext} = require('./functions'); +var {isObjectDeep} = require('./is-object'); +var {ValueFormatter} = require('./ValueFormatter'); /** * @classdesc Represents an event listener for validating not nullable fields. This listener is automatically registered in all data models. @@ -228,8 +230,19 @@ CalculatedValueListener.prototype.beforeSave = function(event, callback) { functionContext.context = event.model.context; //find all attributes that have a default value var attrs = event.model.attributes.filter(function(x) { return (x.calculation!==undefined); }); + // create an instance of ValueFormatter + var valueFormatter = new ValueFormatter(event.model.context, event.model, event.target); async.eachSeries(attrs, function(attr, cb) { var expr = attr.calculation; + // use value formatter for object-like expressions + if (isObjectDeep(expr)) { + return valueFormatter.format(expr).then(function(result) { + event.target[attr.name] = result; + void cb(); + }).catch(function(err) { + void cb(err); + }); + } //validate expression if (typeof expr !== 'string') { event.target[attr.name] = expr; @@ -559,14 +572,24 @@ DefaultValueListener.prototype.beforeSave = function(event, callback) { _.assign(functionContext, event); //find all attributes that have a default value var attrs = event.model.attributes.filter(function(x) { return (typeof x.value!== 'undefined'); }); + // create an instance of ValueFormatter + var valueFormatter = new ValueFormatter(event.model.context, event.model, event.target); async.eachSeries(attrs, function(attr, cb) { try { var expr = attr.value; - //if attribute is already defined - if (typeof event.target[attr.name] !== 'undefined') { - //do nothing - cb(null); - return; + // if attribute is already defined + if (Object.prototype.hasOwnProperty.call(event.target, attr.name)) { + // do nothing + return cb(); + } + // use value formatter for object-like expressions + if (isObjectDeep(expr)) { + return valueFormatter.format(expr).then(function(result) { + event.target[attr.name] = result; + void cb(); + }).catch(function(err) { + void cb(err); + }); } //validate expression if (typeof expr !== 'string') { diff --git a/index.d.ts b/index.d.ts index e4b34ef..d6f42e4 100644 --- a/index.d.ts +++ b/index.d.ts @@ -21,3 +21,4 @@ export * from './data-associations'; export * from './data-application'; export * from './data-errors'; export * from './UnattendedMode'; +export * from './ValueFormatter'; diff --git a/index.js b/index.js index 61223a6..878955a 100644 --- a/index.js +++ b/index.js @@ -89,6 +89,8 @@ var { UnknownAttributeError } = require('./data-errors'); var { executeInUnattendedModeAsync, executeInUnattendedMode, enableUnattendedExecution, disableUnattendedExecution } = require('./UnattendedMode'); +var { ValueFormatter, ValueDialect } = require('./ValueFormatter'); + module.exports = { TypeParser, PrivilegeType, @@ -174,6 +176,8 @@ module.exports = { enableUnattendedExecution, disableUnattendedExecution, executeInUnattendedMode, - executeInUnattendedModeAsync + executeInUnattendedModeAsync, + ValueFormatter, + ValueDialect }; diff --git a/model-schema.json b/model-schema.json index 47a3db5..5aabc03 100644 --- a/model-schema.json +++ b/model-schema.json @@ -16,6 +16,678 @@ "not": { "$ref": "#/definitions/types" } + }, + "stringFunction": { + "type": "object", + "properties": { + "value": { + "anyOf": [ + { "type": "string" }, + { "$ref": "#/definitions/valueType" } + ], + "default": "$field1" + } + }, + "required": [ + "value" + ], + "default": { + "value": "$field1" + }, + "additionalProperties": false + }, + "anyFunctionWithValueParam": { + "type": "object", + "properties": { + "value": { + "anyOf": [ + { "type": "string" }, + { "$ref": "#/definitions/valueType" } + ], + "default": "$field" + } + }, + "required": [ + "value" + ], + "default": { + "value": "$field" + }, + "additionalProperties": false + }, + "comparisonFunction": { + "type": "array", + "items": { + "anyOf": [ + { "type": "string" }, + { "$ref": "#/definitions/valueType" } + ] + }, + "minLength": 2, + "maxLength": 2, + "default": [ + "$field1", + "$field2" + ], + "additionalProperties": false + }, + "anyFunctionWithParams": { + "type": "array", + "items": { + "anyOf": [ + { "type": "string" }, + { "$ref": "#/definitions/valueType" } + ] + }, + "default": [ + "$field1", + "$field2" + ] + }, + "logicalFunction": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "$eq": { + "$ref": "#/definitions/comparisonFunction" + } + } + }, + { + "type": "object", + "properties": { + "$ne": { + "$ref": "#/definitions/comparisonFunction" + } + } + }, + { + "type": "object", + "properties": { + "$gt": { + "$ref": "#/definitions/comparisonFunction" + } + } + }, + { + "type": "object", + "properties": { + "$gte": { + "$ref": "#/definitions/comparisonFunction" + } + } + }, + { + "type": "object", + "properties": { + "$lt": { + "$ref": "#/definitions/comparisonFunction" + } + } + }, + { + "type": "object", + "properties": { + "$lte": { + "$ref": "#/definitions/comparisonFunction" + } + } + } + ] + }, + "minLength": 2, + "default": [ + { + "$eq": [ + "$field1", + "$field2" + ] + }, + { + "$eq": [ + "$field3", + "$field4" + ] + } + ], + "additionalProperties": false + }, + "dateFunctionWithValueParam": { + "type": "object", + "properties": { + "value": { + "anyOf": [ + { "type": "string" }, + { "$ref": "#/definitions/valueType" } + ], + "default": "$dateField1" + } + }, + "required": [ + "value" + ], + "default": { + "value": "$dateField1" + }, + "additionalProperties": false + }, + "valueType": { + "type": "object", + "properties": { + "$date": { + "type": "number", + "default": 1 + }, + "$now": { + "type": "number", + "default": 1 + }, + "$today": { + "type": "number", + "default": 1 + }, + "$year": { + "$ref": "#/definitions/dateFunctionWithValueParam" + }, + "$month": { + "$ref": "#/definitions/dateFunctionWithValueParam" + }, + "$dayOfMonth": { + "$ref": "#/definitions/dateFunctionWithValueParam" + }, + "$hour": { + "$ref": "#/definitions/dateFunctionWithValueParam" + }, + "$minutes": { + "$ref": "#/definitions/dateFunctionWithValueParam" + }, + "$seconds": { + "$ref": "#/definitions/dateFunctionWithValueParam" + }, + "$dateAdd": { + "type": "object", + "properties": { + "startDate": { + "anyOf": [ + { "type": "string" }, + { "$ref": "#/definitions/valueType" } + ], + "default": "$dateField" + }, + "unit": { + "type": "string", + "default": "day" + }, + "amount": { + "type": "number", + "default": 1 + } + }, + "required": [ + "startDate", "unit", "amount" + ], + "default": { + "startDate": "$dateField", + "unit": "day", + "amount": 1 + }, + "additionalProperties": false + }, + "$dateSubtract": { + "type": "object", + "properties": { + "startDate": { + "anyOf": [ + { "type": "string" }, + { "$ref": "#/definitions/valueType" } + ], + "default": "$dateField" + }, + "unit": { + "type": "string", + "default": "day" + }, + "amount": { + "type": "number", + "default": 1 + } + }, + "required": [ + "startDate", "unit", "amount" + ], + "default": { + "startDate": "$dateField", + "unit": "day", + "amount": 1 + }, + "additionalProperties": false + }, + "$user": { + "oneOf": [ + { + "type": "number", + "default": 1 + }, + { + "type": "object", + "properties": { + "property": { + "type": "string", + "default": "name" + } + } + } + ] + }, + "$me": { + "oneOf": [ + { + "type": "number", + "default": 1 + }, + { + "type": "object", + "properties": { + "property": { + "type": "string", + "default": "name" + } + } + } + ] + }, + "$randomString": { + "type": "object", + "properties": { + "length": { + "type": "number", + "default": 8 + } + }, + "required": [ + "length" + ], + "default": { + "length": 8 + }, + "additionalProperties": false + }, + "$chars": { + "type": "object", + "properties": { + "length": { + "type": "number", + "default": 8 + } + }, + "required": [ + "length" + ], + "default": { + "length": 8 + }, + "additionalProperties": false + }, + "$randomNumber": { + "type": "object", + "properties": { + "length": { + "type": "number", + "default": 8 + } + }, + "required": [ + "length" + ], + "default": { + "length": 8 + }, + "additionalProperties": false + }, + "$randomPassword": { + "type": "object", + "properties": { + "length": { + "type": "number", + "default": 8 + } + }, + "required": [ + "length" + ], + "default": { + "length": 8 + }, + "additionalProperties": false + }, + "$randomInt": { + "type": "object", + "properties": { + "min": { + "type": "number", + "default": 0 + }, + "max": { + "type": "number", + "default": 100 + } + }, + "required": [ + "min", "max" + ], + "default": { + "min": 0, + "max": 100 + }, + "additionalProperties": false + }, + "$int": { + "type": "object", + "properties": { + "min": { + "type": "number", + "default": 0 + }, + "max": { + "type": "number", + "default": 100 + } + }, + "required": [ + "min", "max" + ], + "default": { + "min": 0, + "max": 100 + }, + "additionalProperties": false + }, + "$query": { + "type": "object", + "properties": { + "$collection": { + "type": "string" + }, + "$select": { + "type": "object" + }, + "$where": { + "type": "object" + }, + "$order": { + "type": "object" + } + } + }, + "$newGuid": { + "type": "number", + "default": 1 + }, + "$uuid": { + "type": "number", + "default": 1 + }, + "$newId": { + "type": "number", + "default": 1 + }, + "$concat": { + "type": "array", + "items": { + "anyOf": [ + { "type": "string" }, + { "$ref": "#/definitions/valueType" } + ] + }, + "default": [ + "$stringField1", + "$stringField2" + ] + }, + "$toLower": { + "$ref": "#/definitions/stringFunction" + }, + "$toLowerCase": { + "$ref": "#/definitions/stringFunction" + }, + "$toUpper": { + "$ref": "#/definitions/stringFunction" + }, + "$toUpperCase": { + "$ref": "#/definitions/stringFunction" + }, + "$trim": { + "$ref": "#/definitions/stringFunction" + }, + "$length": { + "$ref": "#/definitions/stringFunction" + }, + "$substring": { + "type": "object", + "properties": { + "value": { + "anyOf": [ + { "type": "string" }, + { "$ref": "#/definitions/valueType" } + ], + "default": "$field" + }, + "start": { + "type": "number", + "default": 0 + }, + "length": { + "type": "number", + "default": 1 + } + }, + "required": [ + "value" + ], + "default": { + "value": "$field", + "start": 0, + "length": 1 + }, + "additionalProperties": false + }, + "$abs": { + "$ref": "#/definitions/anyFunctionWithValueParam" + }, + "$ceil": { + "$ref": "#/definitions/anyFunctionWithValueParam" + }, + "$floor": { + "$ref": "#/definitions/anyFunctionWithValueParam" + }, + "$add": { + "$ref": "#/definitions/anyFunctionWithParams" + }, + "$subtract": { + "$ref": "#/definitions/anyFunctionWithParams" + }, + "$multiply": { + "$ref": "#/definitions/anyFunctionWithParams" + }, + "$divide": { + "$ref": "#/definitions/anyFunctionWithParams" + }, + "$mod": { + "$ref": "#/definitions/anyFunctionWithParams" + }, + "$round": { + "type": "object", + "properties": { + "value": { + "$ref": "#/definitions/valueType", + "default": "$field1" + }, + "place": { + "type": "number", + "default": 2 + } + }, + "required": [ + "value", "place" + ], + "default": { + "value": "$field1", + "place": 2 + }, + "additionalProperties": false + }, + "$toString": { + "$ref": "#/definitions/stringFunction" + }, + "$toGuid":{ + "$ref": "#/definitions/stringFunction" + }, + "$toUUID":{ + "$ref": "#/definitions/stringFunction" + }, + "$toInt":{ + "$ref": "#/definitions/stringFunction" + }, + "$toDouble":{ + "$ref": "#/definitions/stringFunction" + }, + "$toDecimal":{ + "$ref": "#/definitions/stringFunction" + }, + "$eq": { + "$ref": "#/definitions/comparisonFunction" + }, + "$ne": { + "$ref": "#/definitions/comparisonFunction" + }, + "$gt": { + "$ref": "#/definitions/comparisonFunction" + }, + "$gte": { + "$ref": "#/definitions/comparisonFunction" + }, + "$lt": { + "$ref": "#/definitions/comparisonFunction" + }, + "$lte": { + "$ref": "#/definitions/comparisonFunction" + }, + "$and": { + "$ref": "#/definitions/logicalFunction" + }, + "$or": { + "$ref": "#/definitions/logicalFunction" + }, + "$cond": { + "type": "object", + "properties": { + "if": { + "anyOf": [ + { "type": "string" }, + { "$ref": "#/definitions/valueType" } + ], + "default": "$field" + }, + "then": { + "anyOf": [ + { "type": "string" }, + { "$ref": "#/definitions/valueType" } + ], + "default": true + }, + "else": { + "anyOf": [ + { "type": "string" }, + { "$ref": "#/definitions/valueType" } + ], + "default": false + } + }, + "required": [ + "if", "then", "else" + ], + "default": { + "if": "$field", + "start": 0, + "length": 1 + }, + "additionalProperties": false + }, + "$replaceAll": { + "type": "object", + "properties": { + "input": { + "anyOf": [ + { "type": "string" }, + { "$ref": "#/definitions/valueType" } + ], + "default": "$field" + }, + "find": { + "anyOf": [ + { "type": "string" }, + { "$ref": "#/definitions/valueType" } + ], + "default": "" + }, + "replacement": { + "anyOf": [ + { "type": "string" }, + { "$ref": "#/definitions/valueType" } + ], + "default": "" + } + }, + "required": [ + "input", "find", "replacement" + ], + "default": { + "input": "$field", + "find": "", + "replacement": "" + }, + "additionalProperties": false + }, + "$replaceOne": { + "type": "object", + "properties": { + "input": { + "anyOf": [ + { "type": "string" }, + { "$ref": "#/definitions/valueType" } + ], + "default": "$field" + }, + "find": { + "anyOf": [ + { "type": "string" }, + { "$ref": "#/definitions/valueType" } + ], + "default": "" + }, + "replacement": { + "anyOf": [ + { "type": "string" }, + { "$ref": "#/definitions/valueType" } + ], + "default": "" + } + }, + "required": [ + "input", "find", "replacement" + ], + "default": { + "input": "$field", + "find": "", + "replacement": "" + }, + "additionalProperties": false + } + }, + "additionalProperties": true } }, "properties": { @@ -133,20 +805,39 @@ "description": "A boolean which indicates whether this attribute is a key column or not." }, "value": { - "type": "string", - "description": "An expression which represents the default value for this attribute." + "description": "An expression which represents the default value for this attribute.", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/definitions/valueType" + } + ] }, "calculation": { - "type": "string", - "description": "An expression which represents the calculated value for this attribute." + "description": "An expression which represents the calculated value for this attribute.", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/definitions/valueType" + } + ] }, "readonly": { "default": false, "type": "boolean", "description": "A boolean which indicates whether this attribute is readonly or not. A readonly value must have a default value or a calculated value." }, + "insertable": { + "default": true, + "type": "boolean", + "description": "A boolean which indicates whether this attribute is insertable or not." + }, "editable": { - "default": false, + "default": true, "type": "boolean", "description": "A boolean which indicates whether this attribute is editable or not." }, @@ -212,7 +903,9 @@ "type": "string", "enum": [ "delete", - "none" + "none", + "null", + "default" ] }, "options": { @@ -265,6 +958,13 @@ "filter": { "type": "string", "description": "A string which represents a filter expression for this privilege. This attribute is used for self privileges which are commonly derived from user's attributes e.g. 'owner eq me()' or 'orderStatus eq 1 and customer eq me()' etc." + }, + "scope": { + "type": "array", + "items": { + "type":"string" + }, + "description": "An array of OAuth2 client scopes as described here https://oauth.net/2/scope/. If current context does not have any of the provided scopes this privilege will be excluded. This option may be used in OAuth2 authorized environments or in any environment which implements such protocols." } }, "required": [ @@ -554,6 +1254,13 @@ "filter": { "type": "string", "description": "A string which represents a filter expression for this privilege. This attribute is used for self privileges which are commonly derived from user's attributes e.g. 'owner eq me()' or 'orderStatus eq 1 and customer eq me()' etc." + }, + "scope": { + "type": "array", + "items": { + "type":"string" + }, + "description": "An array of OAuth2 client scopes as described here https://oauth.net/2/scope/. If current context does not have any of the provided scopes this privilege will be excluded. This option may be used in OAuth2 authorized environments or in any environment which implements such protocols." } }, "required": [ @@ -597,6 +1304,13 @@ "filter": { "type": "string", "description": "A string which represents a filter expression for this privilege. This attribute is used for self privileges which are commonly derived from user's attributes e.g. 'owner eq me()' or 'orderStatus eq 1 and customer eq me()' etc." + }, + "scope": { + "type": "array", + "items": { + "type":"string" + }, + "description": "An array of OAuth2 client scopes as described here https://oauth.net/2/scope/. If current context does not have any of the provided scopes this privilege will be excluded. This option may be used in OAuth2 authorized environments or in any environment which implements such protocols." } }, "required": [ diff --git a/package-lock.json b/package-lock.json index 630b1c9..315008c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,36 +1,41 @@ { "name": "@themost/data", - "version": "2.6.74", + "version": "2.6.75", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@themost/data", - "version": "2.6.74", + "version": "2.6.75", "license": "BSD-3-Clause", "dependencies": { "@themost/events": "^1.3.0", "@themost/promise-sequence": "^1.0.1", "ajv": "^8.17.1", "async": "^2.6.4", + "crypto-js": "^4.2.0", + "esprima": "^4.0.1", "lodash": "^4.17.21", + "moment": "^2.30.1", "node-cache": "^4.2.1", "pluralize": "^7.0.0", "q": "^1.4.1", "sprintf-js": "^1.1.2", - "symbol": "^0.3.1" + "symbol": "^0.3.1", + "uuid": "^11.0.5" }, "devDependencies": { "@babel/plugin-proposal-decorators": "^7.17.2", "@babel/preset-env": "^7.23.8", "@babel/preset-typescript": "^7.16.7", - "@themost/common": "^2.5.11", + "@themost/common": "^2.10.4", "@themost/json-logger": "^1.1.0", "@themost/peers": "^1.0.2", "@themost/query": "^2.6.73", "@themost/sqlite": "^2.8.4", "@themost/xml": "^2.5.2", "@types/core-js": "^2.5.0", + "@types/crypto-js": "^4.2.2", "@types/jest": "^27.4.1", "@types/lodash": "^4.14.149", "@types/node": "^10.12.0", @@ -43,7 +48,6 @@ "eslint-plugin-node": "^11.1.0", "jest": "^29.7.0", "jest-standard-reporter": "^2.0.0", - "moment": "^2.30.1", "sql.js": "^1.4.0", "ts-node": "^9.1.1", "typescript": "^4.2.3" @@ -5174,10 +5178,11 @@ } }, "node_modules/@themost/common": { - "version": "2.5.11", - "resolved": "https://registry.npmjs.org/@themost/common/-/common-2.5.11.tgz", - "integrity": "sha512-n+e/fGodPeDSpDkMxOZHeoUR7ocOhEttBB7mydAiaLm4o5wsD1mLYRc+CJDJjgFb2wiznB4KUZ6oG+fGbn8urw==", + "version": "2.10.4", + "resolved": "https://registry.npmjs.org/@themost/common/-/common-2.10.4.tgz", + "integrity": "sha512-uR2qQfc/GbzgnNjaZ2HaGQ0QRCRMsIY6HdNyLTqWR3Oyg4BSfXzv7Yod/Y96KsjpV+6YAD0bCyc/kuFIGXY+WQ==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "async": "^2.6.4", "blueimp-md5": "^2.7.0", @@ -5380,6 +5385,13 @@ "integrity": "sha512-C4vwOHrhsvxn7UFyk4NDQNUpgNKdWsT/bL39UWyD75KSEOObZSKa9mYDOCM5FGeJG2qtbG0XiEbUKND2+j0WOg==", "dev": true }, + "node_modules/@types/crypto-js": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.2.2.tgz", + "integrity": "sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/emscripten": { "version": "1.39.6", "resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.39.6.tgz", @@ -6407,6 +6419,12 @@ "node": ">= 8" } }, + "node_modules/crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==", + "license": "MIT" + }, "node_modules/date-and-time": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/date-and-time/-/date-and-time-3.1.1.tgz", @@ -7022,7 +7040,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, + "license": "BSD-2-Clause", "bin": { "esparse": "bin/esparse.js", "esvalidate": "bin/esvalidate.js" @@ -10657,7 +10675,7 @@ "version": "2.30.1", "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", - "dev": true, + "license": "MIT", "engines": { "node": "*" } @@ -12186,6 +12204,19 @@ "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==", "dev": true }, + "node_modules/uuid": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.5.tgz", + "integrity": "sha512-508e6IcKLrhxKdBbcA2b4KQZlLVp2+J5UwQ6F7Drckkc5N9ZJwFa4TgWtsww9UG8fGHbm6gbV19TdM5pQ4GaIA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/v8-compile-cache": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz", @@ -16199,9 +16230,9 @@ } }, "@themost/common": { - "version": "2.5.11", - "resolved": "https://registry.npmjs.org/@themost/common/-/common-2.5.11.tgz", - "integrity": "sha512-n+e/fGodPeDSpDkMxOZHeoUR7ocOhEttBB7mydAiaLm4o5wsD1mLYRc+CJDJjgFb2wiznB4KUZ6oG+fGbn8urw==", + "version": "2.10.4", + "resolved": "https://registry.npmjs.org/@themost/common/-/common-2.10.4.tgz", + "integrity": "sha512-uR2qQfc/GbzgnNjaZ2HaGQ0QRCRMsIY6HdNyLTqWR3Oyg4BSfXzv7Yod/Y96KsjpV+6YAD0bCyc/kuFIGXY+WQ==", "dev": true, "requires": { "async": "^2.6.4", @@ -16369,6 +16400,12 @@ "integrity": "sha512-C4vwOHrhsvxn7UFyk4NDQNUpgNKdWsT/bL39UWyD75KSEOObZSKa9mYDOCM5FGeJG2qtbG0XiEbUKND2+j0WOg==", "dev": true }, + "@types/crypto-js": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.2.2.tgz", + "integrity": "sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==", + "dev": true + }, "@types/emscripten": { "version": "1.39.6", "resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.39.6.tgz", @@ -17158,6 +17195,11 @@ "which": "^2.0.1" } }, + "crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==" + }, "date-and-time": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/date-and-time/-/date-and-time-3.1.1.tgz", @@ -17610,8 +17652,7 @@ "esprima": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==" }, "esquery": { "version": "1.4.0", @@ -20362,8 +20403,7 @@ "moment": { "version": "2.30.1", "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", - "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", - "dev": true + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==" }, "ms": { "version": "2.1.2", @@ -21482,6 +21522,11 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "dev": true }, + "uuid": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.5.tgz", + "integrity": "sha512-508e6IcKLrhxKdBbcA2b4KQZlLVp2+J5UwQ6F7Drckkc5N9ZJwFa4TgWtsww9UG8fGHbm6gbV19TdM5pQ4GaIA==" + }, "v8-compile-cache": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz", diff --git a/package.json b/package.json index b5f1471..998320f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@themost/data", - "version": "2.6.74", + "version": "2.6.75", "description": "MOST Web Framework Codename Blueshift - Data module", "main": "index.js", "types": "index.d.ts", @@ -30,24 +30,29 @@ "@themost/promise-sequence": "^1.0.1", "ajv": "^8.17.1", "async": "^2.6.4", + "crypto-js": "^4.2.0", + "esprima": "^4.0.1", "lodash": "^4.17.21", + "moment": "^2.30.1", "node-cache": "^4.2.1", "pluralize": "^7.0.0", "q": "^1.4.1", "sprintf-js": "^1.1.2", - "symbol": "^0.3.1" + "symbol": "^0.3.1", + "uuid": "^11.0.5" }, "devDependencies": { "@babel/plugin-proposal-decorators": "^7.17.2", "@babel/preset-env": "^7.23.8", "@babel/preset-typescript": "^7.16.7", - "@themost/common": "^2.5.11", + "@themost/common": "^2.10.4", "@themost/json-logger": "^1.1.0", "@themost/peers": "^1.0.2", "@themost/query": "^2.6.73", "@themost/sqlite": "^2.8.4", "@themost/xml": "^2.5.2", "@types/core-js": "^2.5.0", + "@types/crypto-js": "^4.2.2", "@types/jest": "^27.4.1", "@types/lodash": "^4.14.149", "@types/node": "^10.12.0", @@ -60,7 +65,6 @@ "eslint-plugin-node": "^11.1.0", "jest": "^29.7.0", "jest-standard-reporter": "^2.0.0", - "moment": "^2.30.1", "sql.js": "^1.4.0", "ts-node": "^9.1.1", "typescript": "^4.2.3" diff --git a/spec/DataObjectAssociationListener.spec.ts b/spec/DataObjectAssociationListener.spec.ts index 1d4eb4e..19ee0dc 100644 --- a/spec/DataObjectAssociationListener.spec.ts +++ b/spec/DataObjectAssociationListener.spec.ts @@ -341,4 +341,64 @@ describe('DataObjectAssociationListener', () => { }); }); + it('should validate foreign key constraint while inserting object', async () => { + await TestUtils.executeInTransaction(context, async () => { + const product = await context.model('Product').asQueryable().where((x: any) => x.name === 'Samsung Galaxy S4').getItem(); + expect(product).toBeTruthy(); + let newOffer: any = { + itemOffered: -400, + price: 999, + validFrom: new Date('2021-12-20'), + validThrough: new Date('2021-12-31') + } + try { + await context.model('Offer').silent().save(newOffer); + throw new Error('An error is expected'); + } catch (error) { + expect(error).toBeInstanceOf(DataObjectAssociationError); + expect(error.model).toEqual('Offer'); + expect(error.field).toEqual('itemOffered'); + } + }); + }); + + it('should validate foreign key constraint while updating object', async () => { + await TestUtils.executeInTransaction(context, async () => { + const product = await context.model('Product').asQueryable().where((x: any) => x.name === 'Samsung Galaxy S4').getItem(); + expect(product).toBeTruthy(); + let newOffer: any = { + itemOffered: product, + price: 999, + validFrom: new Date('2021-12-20'), + validThrough: new Date('2021-12-31') + } + await context.model('Offer').silent().save(newOffer); + newOffer.offeredBy = -400; + try { + await context.model('Offer').silent().save(newOffer); + throw new Error('An error is expected'); + } catch (error) { + expect(error).toBeInstanceOf(DataObjectAssociationError); + expect(error.model).toEqual('Offer'); + expect(error.field).toEqual('offeredBy'); + } + }); + }); + + it('should validate foreign key constraint of readonly fields without calculating values', async () => { + await TestUtils.executeInTransaction(context, async () => { + const product = await context.model('Product').asQueryable().where((x: any) => x.name === 'Samsung Galaxy S4').getItem(); + expect(product).toBeTruthy(); + product.createdBy = -400; + try { + await context.model('Product').silent().save(product); + throw new Error('An error is expected'); + } catch (error) { + expect(error).toBeInstanceOf(DataObjectAssociationError); + expect(error.model).toEqual('Thing'); + expect(error.field).toEqual('createdBy'); + } + }); + }); + }); diff --git a/spec/ValueFormatter.spec.ts b/spec/ValueFormatter.spec.ts new file mode 100644 index 0000000..5a38912 --- /dev/null +++ b/spec/ValueFormatter.spec.ts @@ -0,0 +1,439 @@ +import {TestApplication} from './TestApplication'; +import { DataContext, executeInUnattendedMode, executeInUnattendedModeAsync, ValueFormatter, ValueDialect } from '@themost/data'; +import moment from 'moment'; +import MD5 from 'crypto-js/md5'; +import { resolve } from 'path'; +import { TestUtils } from './adapter/TestUtils'; + +describe('ValueFormatter', () => { + let app: TestApplication; + let context: DataContext; + beforeAll((done) => { + app = new TestApplication(resolve(process.cwd(), 'spec/test2')); + context = app.createContext(); + return done(); + }); + afterAll(async () => { + await context.finalizeAsync(); + await app.finalize(); + }); + + it('should use $now function', async () => { + const formatter = new ValueFormatter(context); + expect(formatter).toBeTruthy(); + const value = await formatter.format({ + $now: [] + }); + expect(value instanceof Date).toBeTruthy(); + }); + + it('should use $randomString function', async () => { + const formatter = new ValueFormatter(context); + expect(formatter).toBeTruthy(); + const value: string = await formatter.format({ + $randomString: [ + 10 + ] + }); + expect(typeof value === 'string').toBeTruthy(); + expect(value.length).toBe(10); + }); + + it('should use $newGuid function', async () => { + const formatter = new ValueFormatter(context); + expect(formatter).toBeTruthy(); + const value: string = await formatter.format({ + $newGuid: 1 + }); + expect(typeof value === 'string').toBeTruthy(); + expect(value.length).toBe(36); + }); + + it('should use $randomString with named params', async () => { + const formatter = new ValueFormatter(context); + expect(formatter).toBeTruthy(); + const value: string = await formatter.format({ + $randomString: { + length: 10 + } + }); + expect(typeof value === 'string').toBeTruthy(); + expect(value.length).toBe(10); + }); + + it('should use $randomInt with named params', async () => { + const formatter = new ValueFormatter(context); + expect(formatter).toBeTruthy(); + const value: string = await formatter.format({ + $randomInt: { + min: 10, + max: 20 + } + }); + expect(typeof value === 'number').toBeTruthy(); + expect(value).toBeGreaterThanOrEqual(10); + expect(value).toBeLessThanOrEqual(20); + }); + + it('should use $randomPassword function', async () => { + const formatter = new ValueFormatter(context); + expect(formatter).toBeTruthy(); + const value: string = await formatter.format({ + $randomPassword: [ + 16 + ] + }); + expect(typeof value === 'string').toBeTruthy(); + expect(value.length).toBe(16); + }); + + + it('should use property syntax', async () => { + const formatter = new ValueFormatter(context, null, { + name: 'Macbook Pro 13', + additionalType: 'Product', + productDimensions: { + width: 30, + height: 20, + depth: 5 + } + }); + expect(formatter).toBeTruthy(); + let value: any = await formatter.format({ + $value: '$$target.additionalType' + }); + expect(typeof value === 'string').toBeTruthy(); + expect(value).toBe('Product'); + value = await formatter.format({ + $value: '$$target.productDimensions.width' + }); + expect(typeof value === 'number').toBeTruthy(); + expect(value).toBe(30); + }); + + it('should use $$model function', async () => { + const formatter = new ValueFormatter(context, context.model('Product')); + expect(formatter).toBeTruthy(); + const value: string = await formatter.format({ + $value: '$$model.name' + }); + expect(typeof value === 'string').toBeTruthy(); + expect(value).toBe('Product'); + }); + + it('should use $newid function', async () => { + const formatter = new ValueFormatter(context, context.model('Product')); + expect(formatter).toBeTruthy(); + const value: string = await formatter.format({ + $newid: 1 + }); + expect(typeof value === 'number').toBeTruthy(); + }); + + it('should use date functions', async () => { + const formatter = new ValueFormatter(context, context.model('Product'), { + name: 'Macbook Pro 13', + dateReleased: new Date(2024, 11, 14) + }); + expect(formatter).toBeTruthy(); + let value: any = await formatter.format({ + $dateAdd: { + startDate: '$$target.dateReleased', + unit: 'day', + amount: 10 + } + }); + expect(value).toStrictEqual(moment(new Date(2024, 11, 24)).toDate()); + value = await formatter.format({ + $dateSubtract: { + startDate: '$$target.dateReleased', + unit: 'day', + amount: 10 + } + }); + expect(value).toStrictEqual(moment(new Date(2024, 11, 4)).toDate()); + }); + + it('should use math functions', async () => { + const formatter = new ValueFormatter(context, context.model('Product'), { + name: 'Macbook Pro 13', + price: 949.458, + dateReleased: new Date(2024, 11, 14) + }); + expect(formatter).toBeTruthy(); + let value: any = await formatter.format({ + $floor: { + value: '$$target.price' + } + }); + expect(value).toBe(949); + value = await formatter.format({ + $round: { + value: '$$target.price', + place: 2 + } + }); + expect(value).toBe(949.46); + value = await formatter.format({ + $ceil: { + value: '$$target.price' + } + }); + expect(value).toBe(950); + + }); + + it('should use comparison operators', async () => { + const formatter = new ValueFormatter(context, context.model('Product'), { + name: 'Macbook Pro 13', + dateReleased: new Date(2024, 11, 14) + }); + expect(formatter).toBeTruthy(); + let value: any = await formatter.format({ + $gt: [ + new Date(2024, 11, 1), + '$$target.dateReleased', + ] + }); + expect(value).toBeFalsy(); + }); + + it('should use $or operator', async () => { + const formatter = new ValueFormatter(context, context.model('Product'), { + name: 'Macbook Pro 13', + dateReleased: new Date(2024, 11, 14), + price: 949.458 + }); + expect(formatter).toBeTruthy(); + let value: any = await formatter.format({ + $or: [ + { + $gt: [ + '$$target.dateReleased', + new Date(2024, 11, 1) + ] + }, + { + $lt: [ + '$$target.price', + 500 + ] + } + ] + }); + expect(value).toBeTruthy(); + }); + + it('should use $and operator', async () => { + const formatter = new ValueFormatter(context, context.model('Product'), { + name: 'Macbook Pro 13', + dateReleased: new Date(2024, 11, 14), + price: 949.458 + }); + expect(formatter).toBeTruthy(); + let value: any = await formatter.format({ + $and: [ + { + $gt: [ + '$$target.dateReleased', + new Date(2024, 11, 1) + ] + }, + { + $lt: [ + '$$target.price', + 500 + ] + } + ] + }); + expect(value).toBeFalsy(); + }); + + it('should use $cond operator', async () => { + const formatter = new ValueFormatter(context, context.model('Product'), { + name: 'Macbook Pro 13', + dateReleased: new Date(2024, 11, 14), + price: 949.458 + }); + expect(formatter).toBeTruthy(); + let value: any = await formatter.format({ + $cond: { + if: { + $gt: [ + '$$target.dateReleased', + new Date(2024, 11, 1) + ] + }, + then: 'Released', + else: 'Not Released' + } + }); + expect(value).toBe('Released'); + }); + + it('should use $replaceAll operator', async () => { + const formatter = new ValueFormatter(context, context.model('Product'), { + name: 'Macbook Pro 13', + dateReleased: new Date(2024, 11, 14), + price: 949.458 + }); + expect(formatter).toBeTruthy(); + let value: any = await formatter.format({ + $replaceAll: { + input: { + $newGuid: 1 + }, + find: '-', + replacement: '' + } + }); + expect(value).toMatch(/^[a-f0-9]{32}$/); + }); + + it('should use $replaceOne operator', async () => { + const formatter = new ValueFormatter(context, context.model('Product'), { + name: 'Macbook Pro 13', + dateReleased: new Date(2024, 11, 14), + price: 949.458 + }); + expect(formatter).toBeTruthy(); + let value: any = await formatter.format({ + $replaceOne: { + input: { + $newGuid: 1 + }, + find: '-', + replacement: '' + } + }); + expect(value).toMatch(/^[a-f0-9\-]{35}$/); + }); + + it('should use $query function', async () => { + const formatter = new ValueFormatter(context, context.model('Product'), { + name: 'Macbook Pro 13', + dateReleased: new Date(2024, 11, 14), + price: 949.458, + defaultOrderStatus: { + name: 'Processing', + } + }); + expect(formatter).toBeTruthy(); + let value: any = await formatter.format({ + $query: { + $collection: 'OrderStatusType', + $select: { + value: '$alternateName' + }, + $where: { + $eq: [ + '$name', + '$$target.defaultOrderStatus.name' + ] + }, + $order: [ + { + $asc: '$name' + } + ] + } + }); + expect(value).toBe('OrderProcessing'); + }); + + it('should use $query function with nested joins', async () => { + await executeInUnattendedModeAsync(context, async () => { + const product = await context.model('Product').where('name').equal('Microsoft Sculpt Mobile Mouse').getItem(); + expect(product).toBeTruthy(); + const formatter = new ValueFormatter(context, context.model('Product'), product); + let lastOrder: any = await formatter.format({ + $query: { + $collection: 'Order', + $select: { + value: '$orderDate' + }, + $where: { + $and: [ + { + $eq: [ + '$orderedItem', + '$$target.id' + ] + }, + { + $eq: [ + '$orderStatus.alternateName', + 'OrderDelivered' + ] + } + ] + }, + $order: [ + { + $desc: '$orderDate' + } + ] + } + }); + expect(lastOrder).toBeTruthy(); + }); + }); + + it('should use custom functions', async () => { + Object.assign(ValueDialect.prototype, { + async $initials(first: string, last: string) { + return `${first.charAt(0)}${last.charAt(0)}`; + } + }); + const formatter = new ValueFormatter(context, context.model('Person'), { + givenName: 'John', + familyName: 'Doe' + }); + const initials = await formatter.format({ + $initials: { + first: '$$target.givenName', + last: '$$target.familyName' + } + }); + expect(initials).toBe('JD'); + }); + + it('should use register for custom dialect functions', async () => { + ValueFormatter.register('initials', { + async $md5 (value: any) { + return MD5(value).toString(); + } + }); + const formatter = new ValueFormatter(context, context.model('Person'), { + givenName: 'John', + familyName: 'Doe' + }); + const hash = await formatter.format({ + $md5: { + value: 'Hello World' + } + }); + expect(hash).toBe(MD5('Hello World').toString()); + }); + + it('should use $user function', async () => { + await TestUtils.executeInTransaction(context, async () => { + context.user = { + name: 'angela.parry@example.com' + }; + const user = await context.model('User').asQueryable().where((x: { id: number, name: string}, username: string) => { + return x.name === username; + }, context.user.name).getItem() + const Products = context.model('Product'); + const formatter = new ValueFormatter(context, Products); + const value: number = await formatter.format({ + $user: 1 + }); + expect(value).toBeTruthy(); + expect(value).toEqual(user.id); + }); + }); + + +}); \ No newline at end of file diff --git a/spec/test2/config/models/Thing.json b/spec/test2/config/models/Thing.json index 013e889..4de6512 100644 --- a/spec/test2/config/models/Thing.json +++ b/spec/test2/config/models/Thing.json @@ -1,12 +1,12 @@ { - "$schema": "https://themost-framework.github.io/themost/models/2018/2/schema.json", + "$schema": "../../../../model-schema.json", "@id": "http://schema.org/Thing", "name": "Thing", "description": "The most generic type of item.", "title": "Thing", "abstract": false, "sealed": false, - "hidden": false, + "hidden": true, "version": "2.0", "fields": [{ "@id": "http://schema.org/sameAs", @@ -88,7 +88,9 @@ "description": "The date on which this item was created.", "type": "DateTime", "readonly": true, - "value": "javascript:return new Date();" + "value": { + "$now": 1 + } }, { "@id": "https://themost.io/schemas/dateModified", @@ -97,27 +99,37 @@ "description": "The date on which this item was most recently modified.", "type": "DateTime", "readonly": true, - "value": "javascript:return (new Date());", - "calculation": "javascript:return (new Date());" + "value": { + "$now": 1 + }, + "calculation": { + "$now": 1 + } }, { "@id": "https://themost.io/schemas/createdBy", "name": "createdBy", "title": "createdBy", "description": "Created by user.", - "type": "Integer", + "type": "User", "readonly": true, - "value": "javascript:return this.user();" + "value": { + "$user": 1 + } }, { "@id": "https://themost.io/schemas/modifiedBy", "name": "modifiedBy", "title": "modifiedBy", "description": "Last modified by user.", - "type": "Integer", + "type": "User", "readonly": true, - "value": "javascript:return this.user();", - "calculation": "javascript:return this.user();" + "value": { + "$user": 1 + }, + "calculation": { + "$user": 1 + } } ], "privileges": [{ diff --git a/spec/test2/db/local.db b/spec/test2/db/local.db index 970892d..0c03f49 100644 Binary files a/spec/test2/db/local.db and b/spec/test2/db/local.db differ