Skip to content

Commit

Permalink
feat(query): basic $expr casting for nested expressions and arithmeti…
Browse files Browse the repository at this point in the history
…c operators

Fix #10663
  • Loading branch information
vkarpov15 committed Jan 22, 2022
1 parent 56691bf commit fc08d92
Show file tree
Hide file tree
Showing 2 changed files with 171 additions and 47 deletions.
165 changes: 122 additions & 43 deletions lib/helpers/query/cast$expr.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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: <number> }
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: [<number>, <number>] }
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 => {
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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;
}
53 changes: 49 additions & 4 deletions test/helpers/query.cast$expr.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,35 +11,80 @@ 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'] });
});

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] }
}
},
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/);
});
});

0 comments on commit fc08d92

Please sign in to comment.