From fc08d9212804cf01caec2fcc535014770965b5ea Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Sat, 22 Jan 2022 13:02:08 -0500 Subject: [PATCH] feat(query): basic $expr casting for nested expressions and arithmetic operators Fix #10663 --- lib/helpers/query/cast$expr.js | 165 ++++++++++++++++++++------- test/helpers/query.cast$expr.test.js | 53 ++++++++- 2 files changed, 171 insertions(+), 47 deletions(-) diff --git a/lib/helpers/query/cast$expr.js b/lib/helpers/query/cast$expr.js index 7c1d31c7b22..769e502b454 100644 --- a/lib/helpers/query/cast$expr.js +++ b/lib/helpers/query/cast$expr.js @@ -4,6 +4,47 @@ const CastError = require('../../error/cast'); const StrictModeError = require('../../error/strict'); const castNumber = require('../../cast/number'); +const booleanComparison = new Set(['$and', '$or', '$not']); +const comparisonOperator = new Set(['$cmp', '$eq', '$lt', '$lte', '$gt', '$gte']); +const arithmeticOperatorArray = new Set([ + '$multiply', + '$divide', + '$log', + '$mod', + '$trunc', + '$avg', + '$max', + '$min', + '$stdDevPop', + '$stdDevSamp', + '$sum' +]); +const arithmeticOperatorNumber = new Set([ + '$abs', + '$exp', + '$ceil', + '$floor', + '$ln', + '$log10', + '$round', + '$sqrt', + '$sin', + '$cos', + '$tan', + '$asin', + '$acos', + '$atan', + '$atan2', + '$asinh', + '$acosh', + '$atanh', + '$sinh', + '$cosh', + '$tanh', + '$degreesToRadians', + '$radiansToDegrees' +]); + module.exports = function cast$expr(val, schema, strictQuery) { if (typeof val !== 'object' || val == null) { throw new Error('`$expr` must be an object'); @@ -18,43 +59,68 @@ function _castExpression(val, schema, strictQuery) { return val; } - if (val.$eq != null) { - val.$eq = castComparison(val.$eq, schema, strictQuery); - if (val.$eq === void 0) { - delete val.$eq; - } - } - if (val.$lt != null) { - val.$lt = castComparison(val.$lt, schema, strictQuery); - if (val.$lt === void 0) { - delete val.$lt; - } + if (val.$cond != null) { + val.$cond.if = _castExpression(val.$cond.if, schema, strictQuery); + val.$cond.then = _castExpression(val.$cond.then, schema, strictQuery); + val.$cond.else = _castExpression(val.$cond.else, schema, strictQuery); + } else if (val.$ifNull != null) { + val.$ifNull.map(v => _castExpression(v, schema, strictQuery)); + } else if (val.$switch != null) { + val.branches.map(v => _castExpression(v, schema, strictQuery)); + val.default = _castExpression(val.default, schema, strictQuery); } - if (val.$lte != null) { - val.$lte = castComparison(val.$lte, schema, strictQuery); - if (val.$lte === void 0) { - delete val.$lte; + + const keys = Object.keys(val); + for (const key of keys) { + if (booleanComparison.has(key)) { + val[key] = val[key].map(v => _castExpression(v, schema, strictQuery)); + } else if (comparisonOperator.has(key)) { + val[key] = castComparison(val[key], schema, strictQuery); + } else if (arithmeticOperatorArray.has(key)) { + val[key] = castArithmetic(val[key], schema, strictQuery); + } else if (arithmeticOperatorNumber.has(key)) { + val[key] = castArithmeticSingle(val[key], schema, strictQuery); } } - if (val.$gte != null) { - val.$gte = castComparison(val.$gte, schema, strictQuery); - if (val.$gte === void 0) { - delete val.$gte; + + _omitUndefined(val); + + return val; +} + +function _omitUndefined(val) { + const keys = Object.keys(val); + for (const key of keys) { + if (val[key] === void 0) { + delete val[key]; } } - if (val.$multiply != null) { - val.$multiply = castMath(val.$multiply, schema, strictQuery); - if (val.$multiply === void 0) { - delete val.$multiply; - } +} + +// { $op: } +function castArithmeticSingle(val) { + if (!isLiteral(val)) { + return val; } - return val; + try { + return castNumber(val); + } catch (err) { + throw new CastError('Number', val); + } } -function castMath(val) { +// { $op: [, ] } +function castArithmetic(val) { if (!Array.isArray(val)) { - throw new Error('Math operator must be an array'); + if (!isLiteral(val)) { + return val; + } + try { + return castNumber(val); + } catch (err) { + throw new CastError('Number', val); + } } return val.map(v => { @@ -64,24 +130,20 @@ function castMath(val) { try { return castNumber(v); } catch (err) { - throw new CastError('Number', v, '$multiiply'); + throw new CastError('Number', v); } }); } +// { $op: [expression, expression] } function castComparison(val, schema, strictQuery) { if (!Array.isArray(val) || val.length !== 2) { throw new Error('Comparison operator must be an array of length 2'); } + val[0] = _castExpression(val[0], schema, strictQuery); const lhs = val[0]; - if (lhs.$cond != null) { - lhs.$cond.if = _castExpression(lhs.$cond.if, schema, strictQuery); - lhs.$cond.then = _castExpression(lhs.$cond.then, schema, strictQuery); - lhs.$cond.else = _castExpression(lhs.$cond.else, schema, strictQuery); - } - if (isLiteral(val[1])) { let path = null; let schematype = null; @@ -117,19 +179,34 @@ function castComparison(val, schema, strictQuery) { } } + const is$literal = typeof val[1] === 'object' && val[1] != null && val[1].$literal != null; if (schematype != null) { - val[1] = schematype.cast(val[1]); + if (is$literal) { + val[1] = { $literal: schematype.cast(val[1].$literal) }; + } else { + val[1] = schematype.cast(val[1]); + } } else if (caster != null) { - try { - val[1] = caster(val[1]); - } catch (err) { - throw new CastError(caster.name.slice(4 /* 'cast'.length */), val[1], path); + if (is$literal) { + try { + val[1] = { $literal: caster(val[1].$literal) }; + } catch (err) { + throw new CastError(caster.name.slice(4 /* 'cast'.length */), val[1], path + '.$literal'); + } + } else { + try { + val[1] = caster(val[1]); + } catch (err) { + throw new CastError(caster.name.slice(4 /* 'cast'.length */), val[1], path); + } } - } else if (strictQuery === true) { + } else if (path != null && strictQuery === true) { return void 0; - } else if (strictQuery === 'throw') { + } else if (path != null && strictQuery === 'throw') { throw new StrictModeError(path); } + } else { + val[1] = _castExpression(val[1]); } return val; @@ -143,8 +220,10 @@ function isLiteral(val) { if (typeof val === 'string' && val.startsWith('$')) { return false; } - if (typeof val === 'object' && val != null && Object.keys(val).includes(key => key.startsWith('$'))) { - return false; + if (typeof val === 'object' && val != null && Object.keys(val).find(key => key.startsWith('$'))) { + // The `$literal` expression can make an object a literal + // https://docs.mongodb.com/manual/reference/operator/aggregation/literal/#mongodb-expression-exp.-literal + return val.$literal != null; } return true; } \ No newline at end of file diff --git a/test/helpers/query.cast$expr.test.js b/test/helpers/query.cast$expr.test.js index 889973b94ba..8a7b954a873 100644 --- a/test/helpers/query.cast$expr.test.js +++ b/test/helpers/query.cast$expr.test.js @@ -11,9 +11,18 @@ describe('castexpr', function() { let res = cast$expr({ $eq: ['$date', '2021-06-01'] }, testSchema); assert.deepEqual(res, { $eq: ['$date', new Date('2021-06-01')] }); + res = cast$expr({ $eq: [{ $year: '$date' }, 2021] }, testSchema); + assert.deepStrictEqual(res, { $eq: [{ $year: '$date' }, 2021] }); + res = cast$expr({ $eq: [{ $year: '$date' }, '2021'] }, testSchema); assert.deepStrictEqual(res, { $eq: [{ $year: '$date' }, 2021] }); + res = cast$expr({ $eq: [{ $year: '$date' }, { $literal: '2021' }] }, testSchema); + assert.deepStrictEqual(res, { $eq: [{ $year: '$date' }, { $literal: 2021 }] }); + + res = cast$expr({ $eq: [{ $year: '$date' }, { $literal: '2021' }] }, testSchema); + assert.deepStrictEqual(res, { $eq: [{ $year: '$date' }, { $literal: 2021 }] }); + res = cast$expr({ $gt: ['$spent', '$budget'] }, testSchema); assert.deepStrictEqual(res, { $gt: ['$spent', '$budget'] }); }); @@ -21,19 +30,19 @@ describe('castexpr', function() { it('casts conditions', function() { const testSchema = new Schema({ price: Number, qty: Number }); - const discountedPrice = { + let discountedPrice = { $cond: { - if: { $gte: ['$qty', '100'] }, + if: { $gte: ['$qty', { $floor: '100' }] }, then: { $multiply: ['$price', '0.5'] }, else: { $multiply: ['$price', '0.75'] } } }; - const res = cast$expr({ $lt: [discountedPrice, 5] }, testSchema); + let res = cast$expr({ $lt: [discountedPrice, 5] }, testSchema); assert.deepStrictEqual(res, { $lt: [ { $cond: { - if: { $gte: ['$qty', 100] }, + if: { $gte: ['$qty', { $floor: 100 }] }, then: { $multiply: ['$price', 0.5] }, else: { $multiply: ['$price', 0.75] } } @@ -41,5 +50,41 @@ describe('castexpr', function() { 5 ] }); + + discountedPrice = { + $cond: { + if: { $and: [{ $gte: ['$qty', { $floor: '100' }] }] }, + then: { $multiply: ['$price', '0.5'] }, + else: { $multiply: ['$price', '0.75'] } + } + }; + res = cast$expr({ $lt: [discountedPrice, 5] }, testSchema); + assert.deepStrictEqual(res, { + $lt: [ + { + $cond: { + if: { $and: [{ $gte: ['$qty', { $floor: 100 }] }] }, + then: { $multiply: ['$price', 0.5] }, + else: { $multiply: ['$price', 0.75] } + } + }, + 5 + ] + }); + }); + + it('casts boolean expressions', function() { + const testSchema = new Schema({ date: Date, spent: Number, budget: Number }); + + const res = cast$expr({ $and: [{ $eq: [{ $year: '$date' }, '2021'] }] }, testSchema); + assert.deepStrictEqual(res, { $and: [{ $eq: [{ $year: '$date' }, 2021] }] }); + }); + + it('cast errors', function() { + const testSchema = new Schema({ date: Date, spent: Number, budget: Number }); + + assert.throws(() => { + cast$expr({ $eq: [{ $year: '$date' }, 'not a number'] }, testSchema); + }, /Cast to Number failed/); }); }); \ No newline at end of file