@env0/dynamo-easy
Version:
DynamoDB client for NodeJS and browser with a fluent api to build requests. We take care of the type mapping between JS and DynamoDB, customizable trough typescript decorators.
308 lines • 12.1 kB
JavaScript
/**
* @module expression
*/
import { curry, isPlainObject } from 'lodash';
import { alterCollectionPropertyMetadataForSingleItem, } from '../../decorator/metadata/property-metadata.model';
import { toDbOne } from '../../mapper/mapper';
import { typeOf } from '../../mapper/util';
import { resolveAttributeNames } from './functions/attribute-names.function';
import { isFunctionOperator } from './functions/is-function-operator.function';
import { isNoParamFunctionOperator } from './functions/is-no-param-function-operator.function';
import { operatorParameterArity } from './functions/operator-parameter-arity.function';
import { uniqueAttributeValueName } from './functions/unique-attribute-value-name.function';
import { validateAttributeType } from './update-expression-builder';
import { dynamicTemplate } from './util';
/**
* see http://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Expressions.ConditionExpressions.html
* https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_Condition.html
*/
/**
* Will walk the object tree recursively and removes all items which do not satisfy the filterFn
* @param obj
* @param {(value: any) => boolean} filterFn
* @returns {any}
* @hidden
*/
export function deepFilter(obj, filterFn) {
if (Array.isArray(obj)) {
const returnArr = [];
obj.forEach(i => {
const item = deepFilter(i, filterFn);
if (item !== null) {
returnArr.push(item);
}
});
return returnArr.length ? returnArr : null;
}
else if (obj instanceof Set) {
const returnArr = [];
Array.from(obj).forEach(i => {
const item = deepFilter(i, filterFn);
if (item !== null) {
returnArr.push(item);
}
});
return returnArr.length ? new Set(returnArr) : null;
}
else if (isPlainObject(obj)) {
const returnObj = {};
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
const value = obj[key];
const item = deepFilter(value, filterFn);
if (item !== null) {
returnObj[key] = item;
}
}
}
return Object.keys(returnObj).length ? returnObj : null;
}
else {
if (filterFn(obj)) {
return obj;
}
else {
return null;
}
}
}
/**
* Will create a condition which can be added to a request using the param object.
* It will create the expression statement and the attribute names and values.
*
* @param {string} attributePath
* @param {ConditionOperator} operator
* @param {any[]} values Depending on the operator the amount of values differs
* @param {string[]} existingValueNames If provided the existing names are used to make sure we have a unique name for the current attributePath
* @param {Metadata<any>} metadata If provided we use the metadata to define the attribute name and use it to map the given value(s) to attributeValue(s)
* @returns {Expression}
* @hidden
*/
export function buildFilterExpression(attributePath, operator, values, existingValueNames, metadata) {
// metadata get rid of undefined values
values = deepFilter(values, value => value !== undefined);
// check if provided values are valid for given operator
validateForOperator(operator, values);
// load property metadata if model metadata was provided
let propertyMetadata;
if (metadata) {
propertyMetadata = metadata.forProperty(attributePath);
}
/*
* resolve placeholder and valuePlaceholder names (same as attributePath if it not already exists)
* myProp -> #myProp for name placeholder and :myProp for value placeholder
*
* person[0] -> #person: person
* person.list[0].age -> #person: person, #attr: attr, #age: age
* person.age
*/
const resolvedAttributeNames = resolveAttributeNames(attributePath, metadata);
const valuePlaceholder = uniqueAttributeValueName(attributePath, existingValueNames);
/*
* build the statement
*/
let buildFilterFn;
switch (operator) {
case 'IN':
buildFilterFn = buildInConditionExpression;
break;
case 'BETWEEN':
buildFilterFn = buildBetweenConditionExpression;
break;
default:
buildFilterFn = curry(buildDefaultConditionExpression)(operator);
}
return buildFilterFn(attributePath, resolvedAttributeNames.placeholder, valuePlaceholder, resolvedAttributeNames.attributeNames, values, existingValueNames, propertyMetadata);
}
/**
* IN expression is unlike all the others property the operand is an array of unwrapped values (not attribute values)
*
* @param {string} attributePath
* @param {string[]} values
* @param {string[]} existingValueNames
* @param {PropertyMetadata<any>} propertyMetadata
* @returns {Expression}
* @hidden
*/
function buildInConditionExpression(attributePath, namePlaceholder, valuePlaceholder, attributeNames, values, existingValueNames, propertyMetadata) {
const attributeValues = values[0]
.map(value => toDbOne(value, propertyMetadata))
.reduce((result, mappedValue, index) => {
if (mappedValue !== null) {
validateAttributeType('IN condition', mappedValue, 'S', 'N', 'B');
result[`${valuePlaceholder}_${index}`] = mappedValue;
}
return result;
}, {});
const inStatement = values[0].map((value, index) => `${valuePlaceholder}_${index}`).join(', ');
return {
statement: `${namePlaceholder} IN (${inStatement})`,
attributeNames,
attributeValues,
};
}
/**
* @hidden
*/
function buildBetweenConditionExpression(attributePath, namePlaceholder, valuePlaceholder, attributeNames, values, existingValueNames, propertyMetadata) {
const attributeValues = {};
const mappedValue1 = toDbOne(values[0], propertyMetadata);
const mappedValue2 = toDbOne(values[1], propertyMetadata);
if (mappedValue1 === null || mappedValue2 === null) {
throw new Error('make sure to provide an actual value for te BETWEEN operator');
}
;
[mappedValue1, mappedValue2].forEach(mv => validateAttributeType('between', mv, 'S', 'N', 'B'));
const value2Placeholder = uniqueAttributeValueName(attributePath, [valuePlaceholder].concat(existingValueNames || []));
const statement = `${namePlaceholder} BETWEEN ${valuePlaceholder} AND ${value2Placeholder}`;
attributeValues[valuePlaceholder] = mappedValue1;
attributeValues[value2Placeholder] = mappedValue2;
return {
statement,
attributeNames,
attributeValues,
};
}
/**
* @hidden
*/
function buildDefaultConditionExpression(operator, attributePath, namePlaceholder, valuePlaceholder, attributeNames, values, existingValueNames, propertyMetadata) {
let statement;
let hasValue = true;
if (isFunctionOperator(operator)) {
if (isNoParamFunctionOperator(operator)) {
statement = `${operator} (${namePlaceholder})`;
hasValue = false;
}
else {
statement = `${operator} (${namePlaceholder}, ${valuePlaceholder})`;
}
}
else {
statement = [namePlaceholder, operator, valuePlaceholder].join(' ');
}
const attributeValues = {};
if (hasValue) {
let attribute;
if (operator === 'contains' || operator === 'not_contains') {
attribute = toDbOne(values[0], alterCollectionPropertyMetadataForSingleItem(propertyMetadata));
validateAttributeType(`${operator} condition`, attribute, 'N', 'S', 'B');
}
else {
attribute = toDbOne(values[0], propertyMetadata);
switch (operator) {
case 'begins_with':
validateAttributeType(`${operator} condition`, attribute, 'S', 'B');
break;
case '<':
case '<=':
case '>':
case '>=':
validateAttributeType(`${operator} condition`, attribute, 'N', 'S', 'B');
break;
}
}
if (attribute) {
attributeValues[valuePlaceholder] = attribute;
}
}
return {
statement,
attributeNames,
attributeValues,
};
}
/**
* Every operator requires a predefined arity of parameters, this method checks for the correct arity and throws an Error
* if not correct
*
* @param operator
* @param values The values which will be applied to the operator function implementation, not every operator requires values
* @throws {Error} error Throws an error if the amount of values won't match the operator function parameter arity or
* the given values is not an array
* @hidden
*/
function validateForOperator(operator, values) {
validateArity(operator, values);
/*
* validate values if operator supports values
*/
if (!isFunctionOperator(operator) || (isFunctionOperator(operator) && !isNoParamFunctionOperator(operator))) {
if (values && Array.isArray(values) && values.length) {
validateValues(operator, values);
}
else {
throw new Error(dynamicTemplate(ERR_ARITY_DEFAULT, { parameterArity: operatorParameterArity(operator), operator }));
}
}
}
// tslint:disable:no-invalid-template-strings
/*
* error messages for arity issues
*/
/**
* @hidden
*/
export const ERR_ARITY_IN = 'expected ${parameterArity} value(s) for operator ${operator}, this is not the right amount of method parameters for this operator (IN operator requires one value of array type)';
/**
* @hidden
*/
export const ERR_ARITY_DEFAULT = 'expected ${parameterArity} value(s) for operator ${operator}, this is not the right amount of method parameters for this operator';
// tslint:enable:no-invalid-template-strings
/**
* @hidden
*/
function validateArity(operator, values) {
if (values === null || values === undefined) {
if (isFunctionOperator(operator) && !isNoParamFunctionOperator(operator)) {
// the operator needs some values to work
throw new Error(dynamicTemplate(ERR_ARITY_DEFAULT, { parameterArity: operatorParameterArity(operator), operator }));
}
}
else if (values && Array.isArray(values)) {
const parameterArity = operatorParameterArity(operator);
// check for correct amount of values
if (values.length !== parameterArity) {
switch (operator) {
case 'IN':
throw new Error(dynamicTemplate(ERR_ARITY_IN, { parameterArity, operator }));
default:
throw new Error(dynamicTemplate(ERR_ARITY_DEFAULT, { parameterArity, operator }));
}
}
}
}
/*
* error message for wrong operator values
*/
// tslint:disable:no-invalid-template-strings
/**
* @hidden
*/
export const ERR_VALUES_BETWEEN_TYPE = 'both values for operator BETWEEN must have the same type, got ${value1} and ${value2}';
/**
* @hidden
*/
export const ERR_VALUES_IN = 'the provided value for IN operator must be an array';
// tslint:enable:no-invalid-template-strings
/**
* Every operator has some constraints about the values it supports, this method makes sure everything is fine for given
* operator and values
* @hidden
*/
function validateValues(operator, values) {
// some additional operator dependent validation
switch (operator) {
case 'BETWEEN':
// values must be the same type
if (typeOf(values[0]) !== typeOf(values[1])) {
throw new Error(dynamicTemplate(ERR_VALUES_BETWEEN_TYPE, { value1: typeOf(values[0]), value2: typeOf(values[1]) }));
}
break;
case 'IN':
if (!Array.isArray(values[0])) {
throw new Error(ERR_VALUES_IN);
}
}
}
//# sourceMappingURL=condition-expression-builder.js.map