MathUtil.java

/*******************************************************************************
 * Copyright (c) 2015 Eclipse RDF4J contributors, Aduna, and others.
 *
 * All rights reserved. This program and the accompanying materials
 * are made available under the terms of the Eclipse Distribution License v1.0
 * which accompanies this distribution, and is available at
 * http://www.eclipse.org/org/documents/edl-v10.php.
 *
 * SPDX-License-Identifier: BSD-3-Clause
 *******************************************************************************/
package org.eclipse.rdf4j.query.algebra.evaluation.util;

import java.math.BigDecimal;
import java.math.BigInteger;
import java.math.MathContext;
import java.math.RoundingMode;

import org.eclipse.rdf4j.model.Literal;
import org.eclipse.rdf4j.model.ValueFactory;
import org.eclipse.rdf4j.model.base.CoreDatatype;
import org.eclipse.rdf4j.model.impl.SimpleValueFactory;
import org.eclipse.rdf4j.query.algebra.MathExpr.MathOp;
import org.eclipse.rdf4j.query.algebra.evaluation.ValueExprEvaluationException;

/**
 * A utility class for evaluation of mathematical expressions on RDF literals.
 *
 * @author Jeen Broekstra
 */
public class MathUtil {

	/**
	 * The default expansion scale used in division operations resulting in a decimal value with non-terminating decimal
	 * expansion. The OpenRDF default is 24 digits, a value used in various other SPARQL implementations, to make
	 * comparison between these systems easy.
	 */
	public static final int DEFAULT_DECIMAL_EXPANSION_SCALE = 24;

	private static int decimalExpansionScale = DEFAULT_DECIMAL_EXPANSION_SCALE;

	public static Literal compute(Literal leftLit, Literal rightLit, MathOp op) throws ValueExprEvaluationException {
		final ValueFactory vf = SimpleValueFactory.getInstance();
		return compute(leftLit, rightLit, op, vf);
	}

	/**
	 * Computes the result of applying the supplied math operator on the supplied left and right operand.
	 *
	 * @param leftLit  a numeric datatype literal
	 * @param rightLit a numeric datatype literal
	 * @param op       a mathematical operator, as definied by MathExpr.MathOp.
	 * @param vf       a ValueFactory used to create the result
	 * @return a numeric datatype literal containing the result of the operation. The result will be datatype according
	 *         to the most specific data type the two operands have in common per the SPARQL/XPath spec.
	 * @throws ValueExprEvaluationException
	 */
	public static Literal compute(Literal leftLit, Literal rightLit, MathOp op, ValueFactory vf)
			throws ValueExprEvaluationException {

		CoreDatatype leftDatatype = leftLit.getCoreDatatype();
		CoreDatatype rightDatatype = rightLit.getCoreDatatype();

		CoreDatatype.XSD leftDatatypeXSD = validateNumericArgument(leftLit, leftDatatype);
		CoreDatatype.XSD rightDatatypeXSD = validateNumericArgument(rightLit, rightDatatype);

		CoreDatatype.XSD commonDatatype = determineCommonDatatype(op, leftDatatypeXSD, rightDatatypeXSD);

		// Note: Java already handles cases like divide-by-zero appropriately
		// for floats and doubles, see:
		// http://www.particle.kth.se/~lindsey/JavaCourse/Book/Part1/Tech/
		// Chapter02/floatingPt2.html

		try {
			switch (commonDatatype) {
			case DOUBLE:
				return computeForXsdDouble(leftLit, rightLit, op, vf);
			case FLOAT:
				return computeForXsdFloat(leftLit, rightLit, op, vf);
			case DECIMAL:
				return computeForXsdDecimal(leftLit, rightLit, op, vf);
			default:
				return computeForXsdInteger(leftLit, rightLit, op, vf);
			}
		} catch (NumberFormatException | ArithmeticException e) {
			throw new ValueExprEvaluationException(e);
		}
	}

	private static CoreDatatype.XSD validateNumericArgument(Literal lit, CoreDatatype datatype) {
		// Only numeric value can be used in math expressions
		if (!datatype.isXSDDatatype()) {
			throw new ValueExprEvaluationException("Not a number: " + lit);
		}
		CoreDatatype.XSD leftDatatypeXSD = (CoreDatatype.XSD) datatype;
		if (!leftDatatypeXSD.isNumericDatatype()) {
			throw new ValueExprEvaluationException("Not a number: " + lit);
		}
		return leftDatatypeXSD;
	}

	private static Literal computeForXsdInteger(Literal leftLit, Literal rightLit, MathOp op, ValueFactory vf) {
		BigInteger left = leftLit.integerValue();
		BigInteger right = rightLit.integerValue();

		switch (op) {
		case PLUS:
			return vf.createLiteral(left.add(right));
		case MINUS:
			return vf.createLiteral(left.subtract(right));
		case MULTIPLY:
			return vf.createLiteral(left.multiply(right));
		case DIVIDE:
			throw new RuntimeException("Integer divisions should be processed as decimal divisions");
		default:
			throw new IllegalArgumentException("Unknown operator: " + op);
		}
	}

	private static Literal computeForXsdDecimal(Literal leftLit, Literal rightLit, MathOp op, ValueFactory vf) {
		BigDecimal left = leftLit.decimalValue();
		BigDecimal right = rightLit.decimalValue();

		switch (op) {
		case PLUS:
			return vf.createLiteral(left.add(right));
		case MINUS:
			return vf.createLiteral(left.subtract(right));
		case MULTIPLY:
			return vf.createLiteral(left.multiply(right));
		case DIVIDE:
			// Divide by zero handled through NumberFormatException
			BigDecimal result;
			try {
				// try to return the exact quotient if possible.
				result = left.divide(right, MathContext.UNLIMITED);
			} catch (ArithmeticException e) {
				// non-terminating decimal expansion in quotient, using
				// scaling and rounding.
				result = left.setScale(getDecimalExpansionScale(), RoundingMode.HALF_UP)
						.divide(right,
								RoundingMode.HALF_UP);
			}

			return vf.createLiteral(result);
		default:
			throw new IllegalArgumentException("Unknown operator: " + op);
		}
	}

	private static Literal computeForXsdFloat(Literal leftLit, Literal rightLit, MathOp op, ValueFactory vf) {
		float left = leftLit.floatValue();
		float right = rightLit.floatValue();

		switch (op) {
		case PLUS:
			return vf.createLiteral(left + right);
		case MINUS:
			return vf.createLiteral(left - right);
		case MULTIPLY:
			return vf.createLiteral(left * right);
		case DIVIDE:
			return vf.createLiteral(left / right);
		default:
			throw new IllegalArgumentException("Unknown operator: " + op);
		}
	}

	private static Literal computeForXsdDouble(Literal leftLit, Literal rightLit, MathOp op, ValueFactory vf) {
		double left = leftLit.doubleValue();
		double right = rightLit.doubleValue();

		switch (op) {
		case PLUS:
			return vf.createLiteral(left + right);
		case MINUS:
			return vf.createLiteral(left - right);
		case MULTIPLY:
			return vf.createLiteral(left * right);
		case DIVIDE:
			return vf.createLiteral(left / right);
		default:
			throw new IllegalArgumentException("Unknown operator: " + op);
		}
	}

	private static CoreDatatype.XSD determineCommonDatatype(MathOp op, CoreDatatype.XSD leftDatatype,
			CoreDatatype.XSD rightDatatype) {
		// Determine most specific datatype that the arguments have in common,
		// choosing from xsd:integer, xsd:decimal, xsd:float and xsd:double as
		// per the SPARQL/XPATH spec
		CoreDatatype.XSD commonDatatype;

		if (leftDatatype.equals(CoreDatatype.XSD.DOUBLE) || rightDatatype.equals(CoreDatatype.XSD.DOUBLE)) {
			commonDatatype = CoreDatatype.XSD.DOUBLE;
		} else if (leftDatatype.equals(CoreDatatype.XSD.FLOAT) || rightDatatype.equals(CoreDatatype.XSD.FLOAT)) {
			commonDatatype = CoreDatatype.XSD.FLOAT;
		} else if (leftDatatype.equals(CoreDatatype.XSD.DECIMAL) || rightDatatype.equals(CoreDatatype.XSD.DECIMAL)) {
			commonDatatype = CoreDatatype.XSD.DECIMAL;
		} else if (op == MathOp.DIVIDE) {
			// Result of integer divide is decimal and requires the arguments to
			// be handled as such, see for details:
			// http://www.w3.org/TR/xpath-functions/#func-numeric-divide
			commonDatatype = CoreDatatype.XSD.DECIMAL;
		} else {
			commonDatatype = CoreDatatype.XSD.INTEGER;
		}
		return commonDatatype;
	}

	/**
	 * Returns the decimal expansion scale used in division operations resulting in a decimal value with non-terminating
	 * decimal expansion. By default, this value is set to 24.
	 *
	 * @return The decimal expansion scale.
	 */
	public static int getDecimalExpansionScale() {
		return decimalExpansionScale;
	}

	/**
	 * Sets the decimal expansion scale used in divisions resulting in a decimal value with non-terminating decimal
	 * expansion.
	 *
	 * @param decimalExpansionScale The decimal expansion scale to set. Note that a mimimum of 18 is required to stay
	 *                              compliant with the XPath specification of xsd:decimal operations.
	 */
	public static void setDecimalExpansionScale(int decimalExpansionScale) {
		MathUtil.decimalExpansionScale = decimalExpansionScale;
	}
}