Source: utils/odata.es.js

/**
 * Copyright (c) 2000-present Liferay, Inc. All rights reserved.
 *
 * This library is free software; you can redistribute it and/or modify it under
 * the terms of the GNU Lesser General Public License as published by the Free
 * Software Foundation; either version 2.1 of the License, or (at your option)
 * any later version.
 *
 * This library is distributed in the hope that it will be useful, but WITHOUT
 * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
 * FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
 * details.
 */

import {filter as oDataFilterFn} from 'odata-v4-parser';

import {
	CONJUNCTIONS,
	FUNCTIONAL_OPERATORS,
	GROUP,
	NOT_OPERATORS,
	PROPERTY_TYPES,
	RELATIONAL_OPERATORS,
} from './constants.es';
import {generateGroupId} from './utils.es';

const EXPRESSION_TYPES = {
	AND: 'AndExpression',
	BOOL_PAREN: 'BoolParenExpression',
	COMMON: 'CommonExpression',
	EQUALS: 'EqualsExpression',
	FIRST_MEMBER: 'FirstMemberExpression',
	GREATER_OR_EQUALS: 'GreaterOrEqualsExpression',
	GREATER_THAN: 'GreaterThanExpression',
	LESSER_OR_EQUALS: 'LesserOrEqualsExpression',
	LESSER_THAN: 'LesserThanExpression',
	MEMBER: 'MemberExpression',
	METHOD_CALL: 'MethodCallExpression',
	NOT: 'NotExpression',
	OR: 'OrExpression',
	PAREN: 'ParenExpression',
	PROPERTY_PATH: 'PropertyPathExpression',
};

const OPERATORS = {
	...FUNCTIONAL_OPERATORS,
	...RELATIONAL_OPERATORS,
};

/**
 * Maps Odata-v4-parser generated AST expression names to internally used
 * constants.
 */
const oDataV4ParserNameMap = {
	[EXPRESSION_TYPES.AND]: CONJUNCTIONS.AND,
	[EXPRESSION_TYPES.BOOL_PAREN]: GROUP,
	contains: OPERATORS.CONTAINS,
	[EXPRESSION_TYPES.EQUALS]: OPERATORS.EQ,
	[EXPRESSION_TYPES.GREATER_OR_EQUALS]: OPERATORS.GE,
	[EXPRESSION_TYPES.GREATER_THAN]: OPERATORS.GT,
	[EXPRESSION_TYPES.LESSER_OR_EQUALS]: OPERATORS.LE,
	[EXPRESSION_TYPES.LESSER_THAN]: OPERATORS.LT,
	[EXPRESSION_TYPES.OR]: CONJUNCTIONS.OR,
};

/**
 * Wraps a node in a grouping node.
 * @param {object} oDataASTNode
 * @param {string} prevConjunction
 * @returns Object representing the grouping
 */
function addNewGroup({oDataASTNode, prevConjunction}) {
	return {
		lastNodeWasGroup: false,
		oDataASTNode: {
			type: EXPRESSION_TYPES.BOOL_PAREN,
			value: oDataASTNode,
		},
		prevConjunction,
	};
}

/**
 * Gets the type of the property from the property name.
 * @param {string} propertyName The property name to find.
 * @param {array} properties The list of defined properties to search in.
 * @returns {string} The property type.
 */
const getTypeByPropertyName = (propertyName, properties) => {
	let type = null;

	if (propertyName && properties) {
		const property = properties.find(
			(property) => property.name === propertyName
		);

		type = property ? property.type : null;
	}

	return type;
};

/**
 * Decides whether to add quotes to value.
 * @param {boolean | string} value
 * @param {boolean | date | number | string} type
 * @returns {string}
 */
function valueParser(value, type) {
	let parsedValue;

	switch (type) {
		case PROPERTY_TYPES.BOOLEAN:
		case PROPERTY_TYPES.DATE:
		case PROPERTY_TYPES.DATE_TIME:
		case PROPERTY_TYPES.INTEGER:
		case PROPERTY_TYPES.DOUBLE:
			parsedValue = value;
			break;
		case PROPERTY_TYPES.COLLECTION:
		case PROPERTY_TYPES.STRING:
		default:
			parsedValue = `'${value}'`;
			break;
	}

	return parsedValue;
}

/**
 * Recursively traverses the criteria object to build an oData filter query
 * string. Properties is required to parse the correctly with or without quotes
 * and formatting the query differently for certain types like collection.
 * @param {object} criteria The criteria object.
 * @param {string} queryConjunction The conjunction name value to be used in the
 * query.
 * @param {array} properties The list of property objects. See
 * ContributorBuilder for valid property object shape.
 * @returns An OData query string built from the criteria object.
 */
function buildQueryString(criteria, queryConjunction, properties) {
	return criteria.filter(Boolean).reduce((queryString, criterion, index) => {
		const {
			conjunctionName,
			items,
			operatorName,
			propertyName,
			value,
		} = criterion;

		if (index > 0) {
			queryString = queryString.concat(` ${queryConjunction} `);
		}

		if (conjunctionName) {
			queryString = queryString.concat(
				`(${buildQueryString(items, conjunctionName, properties)})`
			);
		}
		else {
			const type =
				criterion.type ||
				getTypeByPropertyName(propertyName, properties);

			const parsedValue = valueParser(value, type);

			if (isValueType(RELATIONAL_OPERATORS, operatorName)) {
				if (type === PROPERTY_TYPES.COLLECTION) {
					queryString = queryString.concat(
						`${propertyName}/any(c:c ${operatorName} ${parsedValue})`
					);
				}
				else {
					queryString = queryString.concat(
						`${propertyName} ${operatorName} ${parsedValue}`
					);
				}
			}
			else if (isValueType(FUNCTIONAL_OPERATORS, operatorName)) {
				if (type === PROPERTY_TYPES.COLLECTION) {
					queryString = queryString.concat(
						`${propertyName}/any(c:${operatorName}(c, ${parsedValue}))`
					);
				}
				else {
					queryString = queryString.concat(
						`${operatorName}(${propertyName}, ${parsedValue})`
					);
				}
			}
			else if (isValueType(NOT_OPERATORS, operatorName)) {
				const baseOperator = operatorName.replace(/not-/g, '');

				const baseExpression = [
					{
						operatorName: baseOperator,
						propertyName,
						type,
						value,
					},
				];

				// Not is wrapped in a group to simplify AST parsing.

				queryString = queryString.concat(
					`(not (${buildQueryString(
						baseExpression,
						conjunctionName,
						properties
					)}))`
				);
			}
		}

		return queryString;
	}, '');
}

/**
 * Prepares odata query by encoding special characters in string values.
 * @param {string} queryString The odata query.
 * @returns {string} The encoded odata query.
 */
function encodeQueryString(queryString) {
	return queryString.split(' ').reduce((query, queryPartial) => {
		const formattedPartial = queryPartial.startsWith("'")
			? encodeURIComponent(queryPartial)
			: queryPartial;

		return `${query} ${formattedPartial}`;
	});
}

/**
 * Removes both single `'` and double `"` quotes from a string as well as decode
 * special characters.
 * @param {string} criterionValue The string to remove quotes and decode.
 * @returns {string} The decoded string without quotes.
 */
function formatCriterionValue(criterionValue) {
	return decodeURIComponent(criterionValue).replace(/['"]+/g, '');
}

/**
 * Gets the internal name of a child expression from the oDataV4Parser name.
 * @param {object} oDataASTNode
 * @returns String value of the internal name.
 */
function getChildExpressionName(oDataASTNode) {
	return getExpressionName(oDataASTNode.value);
}

/**
 * Gets the conjunction of the group or returns AND as a default.
 * @param {object} oDataASTNode
 * @returns The conjunction name for a group or, if not available, AND.
 */
function getConjunctionForGroup(oDataASTNode) {
	const childExpressionName = getChildExpressionName(oDataASTNode);

	return isValueType(CONJUNCTIONS, childExpressionName)
		? childExpressionName
		: CONJUNCTIONS.AND;
}

/**
 * Gets the internal name of an expression from the oDataV4Parser name.
 * @param {object} oDataASTNode
 * @returns String value of the internal name
 */
function getExpressionName(oDataASTNode) {
	const type = oDataASTNode.type;

	let returnValue = oDataV4ParserNameMap[type];

	if (type == EXPRESSION_TYPES.METHOD_CALL) {
		returnValue = oDataASTNode.value.method;
	}

	return returnValue;
}

function getFunctionName(oDataASTNode) {
	return oDataV4ParserNameMap[oDataASTNode.value.method];
}

/**
 * Returns the next expression in the syntax tree that is not a grouping.
 * @param {object} oDataASTNode
 * @returns String value of the internal name of the next expression.
 */
const getNextNonGroupExpression = (oDataASTNode) => {
	let returnValue;

	if (oDataASTNode.value.type === EXPRESSION_TYPES.BOOL_PAREN) {
		returnValue = getNextNonGroupExpression(oDataASTNode.value);
	}
	else {
		returnValue = oDataASTNode.value.left
			? oDataASTNode.value.left
			: oDataASTNode.value;
	}

	return returnValue;
};

/**
 * Returns the next expression in the syntax tree that is not a grouping.
 * Also ignoring Common, Paren, Member, and FirstMember expressions for property
 * path expression types like `cookies/any(c:c eq 'key=value')` since the
 * expressions' value are the same for a collection query.
 * @param {object} oDataASTNode
 * @returns String value of the internal name of the next expression.
 */
const getNextOperatorExpression = (oDataASTNode) => {
	let returnValue;

	const nextNode = oDataASTNode.value.left
		? oDataASTNode.value.left
		: oDataASTNode.value;

	const type = nextNode.type;

	if (
		type === EXPRESSION_TYPES.BOOL_PAREN ||
		type === EXPRESSION_TYPES.AND ||
		type === EXPRESSION_TYPES.OR ||
		type === EXPRESSION_TYPES.COMMON ||
		type === EXPRESSION_TYPES.FIRST_MEMBER ||
		type === EXPRESSION_TYPES.MEMBER ||
		type === EXPRESSION_TYPES.PAREN
	) {
		returnValue = getNextOperatorExpression(nextNode);
	}
	else {
		returnValue = nextNode;
	}

	return returnValue;
};

/**
 * Checks if a grouping has different conjunctions (e.g. (x AND y OR z)).
 * @param {boolean} lastNodeWasGroup
 * @param {object} oDataASTNode
 * @param {string} prevConjunction
 * @returns boolean of whether a grouping has different conjunctions.
 */
function hasDifferentConjunctions({
	lastNodeWasGroup,
	oDataASTNode,
	prevConjunction,
}) {
	return prevConjunction !== oDataASTNode.type && !lastNodeWasGroup;
}

/**
 * Checks if the criteria is a group by checking if it has an `items` property.
 * @param {object} criteria
 */
function isCriteriaGroup(criteria) {
	return !!criteria.items;
}

/**
 * Checks if the value is a certain type.
 * @param {object} types A map of supported types.
 * @param {*} value The value to validate.
 * @returns {boolean}
 */
function isValueType(types, value) {
	return Object.values(types).includes(value);
}

/**
 * Checks if the group is needed; It is unnecessary when there are multiple
 * groupings in a row, when the conjunction directly outside the group is the
 * same as the one inside or there is no conjunction within a grouping.
 * @param {boolean} lastNodeWasGroup
 * @param {object} oDataASTNode
 * @param {string} prevConjunction
 * @returns a boolean of whether a group is necessary.
 */
function isRedundantGroup({lastNodeWasGroup, oDataASTNode, prevConjunction}) {
	const nextNodeExpressionName = getExpressionName(
		getNextNonGroupExpression(oDataASTNode)
	);

	return (
		lastNodeWasGroup ||
		oDataV4ParserNameMap[prevConjunction] === nextNodeExpressionName ||
		!isValueType(CONJUNCTIONS, nextNodeExpressionName)
	);
}

/**
 * Removes a grouping node and returns the child node
 * @param {object} oDataASTNode
 * @param {string} prevConjunction
 * @returns Object representing the operation inside the grouping
 */
function skipGroup({oDataASTNode, prevConjunction}) {
	return {
		lastNodeWasGroup: true,
		oDataASTNode: oDataASTNode.value,
		prevConjunction,
	};
}

/**
 * Converts an OData filter query string to an object that can be used by the
 * criteria builder
 * @param {string} queryString
 * @returns {object} Criteria representation of the query string
 */
function translateQueryToCriteria(queryString) {
	let criteria;

	try {
		if (queryString === '()') {
			throw 'queryString is ()';
		}

		const oDataASTNode = oDataFilterFn(encodeQueryString(queryString));

		const criteriaArray = toCriteria({oDataASTNode});

		criteria = isCriteriaGroup(criteriaArray[0])
			? criteriaArray[0]
			: wrapInCriteriaGroup(criteriaArray);
	}
	catch (e) {
		criteria = null;
	}

	return criteria;
}

/**
 * Recursively transforms the AST generated by the odata-v4-parser library into
 * a shape the criteria builder expects. Returns an array so that left and right
 * arguments can be concatenated together.
 * @param {object} context
 * @param {object} context.oDataASTNode
 * @returns Criterion representation of an AST expression node in an array
 */
function toCriteria(context) {
	const {oDataASTNode} = context;

	const expressionName = getExpressionName(oDataASTNode);

	let criterion;

	if (oDataASTNode.type === EXPRESSION_TYPES.NOT) {
		criterion = transformNotNode(context);
	}
	else if (oDataASTNode.type === EXPRESSION_TYPES.COMMON) {
		criterion = transformCommonNode(context);
	}
	else if (oDataASTNode.type === EXPRESSION_TYPES.METHOD_CALL) {
		criterion = transformFunctionalNode(context);
	}
	else if (isValueType(RELATIONAL_OPERATORS, expressionName)) {
		criterion = transformOperatorNode(context);
	}
	else if (isValueType(CONJUNCTIONS, expressionName)) {
		criterion = transformConjunctionNode(context);
	}
	else if (expressionName === GROUP) {
		criterion = transformGroupNode(context);
	}

	return criterion;
}

/**
 * Transform an operator expression node into a criterion for the criteria
 * builder.
 * @param {object} oDataASTNode
 * @returns An array containing the object representation of a collection
 * criterion.
 */
function transformCommonNode({oDataASTNode}) {
	const nextNodeExpression = getNextOperatorExpression(oDataASTNode);

	const anyExpression = nextNodeExpression.value.next.value;

	const methodExpression = anyExpression.value.predicate.value;

	const methodExpressionName = getExpressionName(methodExpression);

	let value;

	if (methodExpressionName == OPERATORS.CONTAINS) {
		value = formatCriterionValue(methodExpression.value.parameters[1].raw);
	}
	else if (methodExpressionName == OPERATORS.EQ) {
		value = formatCriterionValue(methodExpression.value.right.raw);
	}

	return [
		{
			operatorName: methodExpressionName,
			propertyName: nextNodeExpression.value.current.raw,
			value,
		},
	];
}

/**
 * Transforms conjunction expression node into a criterion for the criteria
 * builder. If it comes across a grouping sharing an AND and OR conjunction, it
 * will add a new grouping so the criteria builder doesn't require a user to
 * know operator precedence.
 * @param {object} context
 * @param {object} context.oDataASTNode
 * @returns an array containing the concatenated left and right values of a
 * conjunction expression or a new grouping.
 */
function transformConjunctionNode(context) {
	const {oDataASTNode} = context;

	const conjunctionType = oDataASTNode.type;
	const nextNode = oDataASTNode.value;

	return hasDifferentConjunctions(context)
		? toCriteria(addNewGroup(context))
		: [
				...toCriteria({
					oDataASTNode: nextNode.left,
					prevConjunction: conjunctionType,
				}),
				...toCriteria({
					oDataASTNode: nextNode.right,
					prevConjunction: conjunctionType,
				}),
		  ];
}

/**
 * Transform a function expression node into a criterion for the criteria
 * builder.
 * @param {object} oDataASTNode
 * @returns an array containing the object representation of an operator
 * criterion
 */
function transformFunctionalNode({oDataASTNode}) {
	return [
		{
			operatorName: getFunctionName(oDataASTNode),
			propertyName: oDataASTNode.value.parameters[0].raw,
			value: formatCriterionValue(oDataASTNode.value.parameters[1].raw),
		},
	];
}

/**
 * Transforms a group expression node into a criterion for the criteria
 * builder. If it comes across a grouping that is redundant (doesn't provide
 * readability improvements, superfluous to order of operations), it will remove
 * it.
 * @param {object} context
 * @param {object} context.oDataASTNode
 * @param {string} context.prevConjunction
 * @returns Criterion representation of an AST expression node in an array
 */
function transformGroupNode(context) {
	const {oDataASTNode, prevConjunction} = context;

	return isRedundantGroup(context)
		? toCriteria(skipGroup(context))
		: [
				{
					conjunctionName: getConjunctionForGroup(oDataASTNode),
					groupId: generateGroupId(),
					items: toCriteria({
						lastNodeWasGroup: true,
						oDataASTNode: oDataASTNode.value,
						prevConjunction,
					}),
				},
		  ];
}

/**
 * Transform an operator expression node into a criterion for the criteria
 * builder.
 * @param {object} oDataASTNode
 * @returns an array containing the object representation of an operator
 * criterion
 */
function transformNotNode({oDataASTNode}) {
	const nextNodeExpression = getNextOperatorExpression(oDataASTNode);

	const nextNodeExpressionName = getExpressionName(nextNodeExpression);

	let returnValue;

	if (nextNodeExpressionName == OPERATORS.CONTAINS) {
		returnValue = [
			{
				operatorName: NOT_OPERATORS.NOT_CONTAINS,
				propertyName: nextNodeExpression.value.parameters[0].raw,
				value: formatCriterionValue(
					nextNodeExpression.value.parameters[1].raw
				),
			},
		];
	}
	else if (nextNodeExpressionName == OPERATORS.EQ) {
		returnValue = [
			{
				operatorName: NOT_OPERATORS.NOT_EQ,
				propertyName: nextNodeExpression.value.left.raw,
				value: formatCriterionValue(nextNodeExpression.value.right.raw),
			},
		];
	}
	else if (nextNodeExpression.type == EXPRESSION_TYPES.PROPERTY_PATH) {
		const anyExpression = nextNodeExpression.value.next.value;

		const methodExpression = anyExpression.value.predicate.value;

		const methodExpressionName = getExpressionName(methodExpression);

		if (methodExpressionName == OPERATORS.CONTAINS) {
			returnValue = [
				{
					operatorName: NOT_OPERATORS.NOT_CONTAINS,
					propertyName: nextNodeExpression.value.current.raw,
					value: formatCriterionValue(
						methodExpression.value.parameters[1].raw
					),
				},
			];
		}
		else if (methodExpressionName == OPERATORS.EQ) {
			returnValue = [
				{
					operatorName: NOT_OPERATORS.NOT_EQ,
					propertyName: nextNodeExpression.value.current.raw,
					value: formatCriterionValue(
						methodExpression.value.right.raw
					),
				},
			];
		}
	}

	return returnValue;
}

/**
 * Transform an operator expression node into a criterion for the criteria
 * builder.
 * @param {object} oDataASTNode
 * @returns an array containing the object representation of an operator
 * criterion
 */
function transformOperatorNode({oDataASTNode}) {
	return [
		{
			operatorName: getExpressionName(oDataASTNode),
			propertyName: oDataASTNode.value.left.raw,
			value: formatCriterionValue(oDataASTNode.value.right.raw),
		},
	];
}

/**
 * Wraps the criteria items in a criteria group.
 * @param {array} criteriaArray The list of criterion.
 */
function wrapInCriteriaGroup(criteriaArray) {
	return {
		conjunctionName: CONJUNCTIONS.AND,
		groupId: generateGroupId(),
		items: criteriaArray,
	};
}

export {buildQueryString, translateQueryToCriteria};