All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.xlrit.gears.base.function.DefaultFunctions Maven / Gradle / Ivy

There is a newer version: 1.17.5
Show newest version
package com.xlrit.gears.base.function;

import java.math.BigDecimal;
import java.math.MathContext;
import java.math.RoundingMode;
import java.text.DecimalFormat;
import java.text.DecimalFormatSymbols;
import java.text.NumberFormat;
import java.text.ParseException;
import java.time.*;
import java.time.format.DateTimeFormatter;
import java.time.temporal.*;
import java.util.*;
import java.util.function.BiFunction;
import java.util.function.Function;
import java.util.function.Supplier;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.LongStream;
import java.util.stream.Stream;

import ch.obermuhlner.math.big.BigDecimalMath;
import com.google.common.base.Preconditions;
import com.google.common.collect.Iterables;
import com.google.common.collect.Lists;
import com.xlrit.gears.base.content.ContentRef;
import com.xlrit.gears.base.exception.SpecException;
import com.xlrit.gears.base.execution.Execution;
import com.xlrit.gears.base.id.IdGenerator;
import com.xlrit.gears.base.id.SequenceValueProducer;
import com.xlrit.gears.base.model.Document;
import com.xlrit.gears.base.model.User;
import com.xlrit.gears.base.util.PasswordEncoderHolder;
import com.xlrit.gears.base.util.PeriodDurationHelper;
import com.xlrit.gears.base.util.StringUtils;
import com.xlrit.gears.base.util.TemporalUtils;
import jakarta.annotation.Nullable;
import lombok.SneakyThrows;
import org.apache.commons.text.StringEscapeUtils;
import org.apache.commons.text.WordUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.threeten.extra.Months;
import org.threeten.extra.PeriodDuration;
import org.threeten.extra.YearWeek;

import static com.xlrit.gears.base.function.Optional.optional;
import static com.xlrit.gears.base.util.ErrorUtils.inputError;
import static com.xlrit.gears.base.util.ErrorUtils.languageError;
import static com.xlrit.gears.base.util.StringUtils.toLocale;
import static com.xlrit.gears.base.util.StringUtils.toSnakeCase;
import static com.xlrit.gears.base.util.TemporalUtils.*;

public class DefaultFunctions {
	private static final Logger LOG = LoggerFactory.getLogger(DefaultFunctions.class);

	// === type conversion functions === //

	// locales
	private static final String defaultLanguageTag = "en";
	private static final Locale defaultLocale = toLocale(defaultLanguageTag);

	// numbers
	private static final String defaultNumberPattern = "###0.###";
	private static final DecimalFormat defaultNumberFormatter = new DecimalFormat(defaultNumberPattern, DecimalFormatSymbols.getInstance(defaultLocale));

	// date/times
	public static final String defaultDatePattern = "uuuu-MM-dd";
	public static final String defaultTimePattern = "HH:mm:ss";
	public static final String defaultDateTimePattern = defaultDatePattern + "'T'" + defaultTimePattern + "X";
	private static final DateTimeFormatter defaultDateFormatter     = DateTimeFormatter.ofPattern(defaultDatePattern);
	private static final DateTimeFormatter defaultTimeFormatter     = DateTimeFormatter.ofPattern(defaultTimePattern);
	private static final DateTimeFormatter defaultDateTimeFormatter = DateTimeFormatter.ofPattern(defaultDateTimePattern);

	@FunctionDef(category = "conversion")
	public static boolean isBoolean(String text) {
		if (text == null) return false;
		String upperText = text.toUpperCase();
		return "TRUE".equals(upperText) || "FALSE".equals(upperText);
	}

	@FunctionDef(category = "conversion")
	public static Boolean toBoolean(Boolean optionalPredicate) {
		// TODO: this should break when optionalPredicate is null.
		return Objects.requireNonNullElse(optionalPredicate, false);
	}

	@FunctionDef(category = "conversion")
	public static Boolean toBoolean(String value) {
		return toBoolean(value, defaultLanguageTag);
	}

	@FunctionDef(category = "conversion")
	public static Boolean toBoolean(String value, String languageTag) {
		return toBoolean(value, toLocale(languageTag));
	}

	private static Boolean toBoolean(String value, Locale locale) {
		return switch (locale.getLanguage()) {
			case "en" -> toBoolean(value, "true", "false");
			case "nl" -> toBoolean(value, "waar", "onwaar");
			default   -> throw languageError("toBoolean", locale.getLanguage());
		};
	}

	private static Boolean toBoolean(String value, String trueString, String falseString) {
		return toBoolean(value, trueString::equalsIgnoreCase, falseString::equalsIgnoreCase);
	}

	private static  Boolean toBoolean(T value, Function trueLambda, Function falseLambda) {
		if (value == null)
			throw inputError(value, "toBoolean", "Boolean");
		else if (trueLambda.apply(value))
			return true;
		else if (falseLambda.apply(value))
			return false;
		else {
			throw inputError(value, "toBoolean", "Boolean");
		}
	}

	@FunctionDef(category = "conversion")
	public static Boolean toBoolean(long value) {
		return toBoolean(value, (i) -> i == 1, (i) -> i == 0);
	}

	@FunctionDef(category = "conversion")
	public static boolean isInteger(String value) {
		if (value == null) return false;
		try {
			Long.parseLong(value);
			return true;
		}
		catch (NumberFormatException e) {
			return false;
		}
	}

	@FunctionDef(category = "conversion")
	public static long toInteger(String value) {
		return toInteger(value, defaultLanguageTag);
	}

	@FunctionDef(category = "conversion")
	public static long toInteger(String value, String languageTag) {
		try {
			NumberFormat nf = NumberFormat.getInstance(toLocale(languageTag));
			Number n = nf.parse(normalizeNumber(value));
			if (n.doubleValue() % 1 != 0)
				throw inputError(value, "toInteger", "integer");

			return n.longValue();
		}
		catch (ParseException e) {
			throw inputError(value, "toInteger", "integer", e);
		}
	}

	@FunctionDef(category = "conversion")
	public static BigDecimal toDecimal(Long value) {
		return BigDecimal.valueOf(value);
	}

	@FunctionDef(category = "conversion")
	public static boolean isDecimal(String value) {
		if (value == null) return false;
		try {
			toDecimal_raw(value, defaultLanguageTag);
			return true;
		}
		catch (ParseException e) {
			return false;
		}
	}

	@FunctionDef(category = "conversion")
	public static BigDecimal toDecimal(String value) {
		return toDecimal(value, defaultLanguageTag);
	}

	@FunctionDef(category = "conversion")
	public static BigDecimal toDecimal(String value, String languageTag) {
		try {
			return toDecimal_raw(value, languageTag);
		}
		catch (ParseException e) {
			throw inputError(value, "toDecimal", "decimal", e);
		}
	}

	public static BigDecimal toDecimal_raw(String value, String languageTag) throws ParseException {
		var df = (DecimalFormat) NumberFormat.getInstance(toLocale(languageTag));
		df.setParseBigDecimal(true);
		return (BigDecimal) df.parse(normalizeNumber(value));
	}

	@FunctionDef(category = "conversion")
	public static boolean isDate(String dateText) {
		if (dateText == null) return false;
		try {
			TemporalUtils.parseDate(dateText);
			return true;
		}
		catch (Exception e) {
			return false;
		}
	}

	@FunctionDef(category = "conversion")
	public static LocalDate toDate(String dateText) {
		try {
			return TemporalUtils.parseDate(dateText);
		}
		catch (Exception e) {
			throw inputError(dateText, "toDate", "date", e);
		}
	}

	@FunctionDef(category = "conversion")
	public static LocalDate toDate(String dateText, String pattern, String languageTag) {
		try {
			DateTimeFormatter formatter = DateTimeFormatter.ofPattern(pattern, toLocale(languageTag));
			return TemporalUtils.parseDate(dateText, formatter);
		}
		catch (Exception e) {
			throw inputError(dateText, "toDate", "date", e);
		}
	}

	@FunctionDef(category = "conversion")
	public static boolean isTime(String timeText) {
		if (timeText == null) return false;
		try {
			TemporalUtils.parseTime(timeText);
			return true;
		}
		catch (Exception e) {
			return false;
		}
	}

	@FunctionDef(category = "conversion")
	public static LocalTime toTime(String timeText) {
		try {
			return TemporalUtils.parseTime(timeText);
		}
		catch (Exception e) {
			throw inputError(timeText, "toTime", "time", e);
		}
	}

	@FunctionDef(category = "conversion")
	public static LocalTime toTime(String timeText, String pattern) {
		try {
			DateTimeFormatter formatter = DateTimeFormatter.ofPattern(pattern, toLocale(defaultLanguageTag));
			return TemporalUtils.parseTime(timeText, formatter);
		}
		catch (Exception e) {
			throw inputError(timeText, "toTime", "time", e);
		}
	}

	@FunctionDef(category = "conversion")
	public static boolean isDatetime(String dateTimeText) {
		if (dateTimeText == null) return false;
		try {
			TemporalUtils.parseDateTime(dateTimeText);
			return true;
		}
		catch (Exception e) {
			return false;
		}
	}

	@FunctionDef(category = "conversion")
	public static OffsetDateTime toDatetime(String dateTimeText) {
		try {
			return TemporalUtils.parseDateTime(dateTimeText);
		}
		catch (Exception e) {
			throw inputError(dateTimeText, "toDatetime", "datetime", e);
		}
	}

	@FunctionDef(category = "conversion")
	public static OffsetDateTime toDatetime(String dateTimeText, String pattern, String languageTag) {
		try {
			DateTimeFormatter formatter = DateTimeFormatter.ofPattern(pattern, toLocale(languageTag));
			return TemporalUtils.parseDateTime(dateTimeText, formatter);
		}
		catch (Exception e) {
			throw inputError(dateTimeText, "toDateTime", "datetime", e);
		}
	}

	@FunctionDef(category = "conversion")
	public static OffsetDateTime toDatetime(LocalDate date, LocalTime time, String zoneName) {
		ZoneId zoneId = ZoneId.of(zoneName);
		return date.atTime(time).atZone(zoneId).toOffsetDateTime();
	}

	@FunctionDef(category = "conversion")
	public static boolean isPeriod(String periodText) {
		if (periodText == null) return false;
		return PeriodDurationHelper.forLanguageTag(defaultLanguageTag).isValid(periodText);
	}

	@FunctionDef(category = "conversion")
	public static PeriodDuration toPeriod(String periodText) {
		return toPeriod(periodText, defaultLanguageTag);
	}

	@FunctionDef(category = "conversion")
	public static PeriodDuration toPeriod(String periodText, String languageTag) {
		try {
			return PeriodDurationHelper.forLanguageTag(languageTag).parse(periodText);
		}
		catch (Exception e) {
			throw inputError(periodText, "toPeriod", "period", e);
		}
	}

	@FunctionDef(category = "conversion")
	public static String toText(PeriodDuration period) {
		return toText(period, defaultLanguageTag);
	}

	@FunctionDef(category = "conversion")
	public static String toText(PeriodDuration value, String languageTag) {
		return PeriodDurationHelper.forLanguageTag(languageTag).format(value);
	}

	@FunctionDef(category = "conversion")
	public static String toText(PeriodDuration value, String languageTag, String format) {
		return PeriodDurationHelper.forLanguageTag(languageTag).format(value, format);
	}

	@FunctionDef(category = "conversion")
	public static String toText(Boolean value) {
		return toText(value, defaultLanguageTag);
	}

	@FunctionDef(category = "conversion")
	public static String toText(Boolean value, String languageTag) {
		Locale locale = toLocale(languageTag);
		return switch (locale.getLanguage()) {
			case "en" -> value ? "true" : "false";
			case "nl" -> value ? "waar" : "onwaar";
			default   -> throw languageError("toText", locale.getLanguage());
		};
	}

	@FunctionDef(category = "conversion")
	public static String toText(Number value) {
		return defaultNumberFormatter.format(value);
	}

	@FunctionDef(category = "conversion")
	public static String toText(Number value, String pattern) {
		return toText(value, pattern, defaultLanguageTag);
	}

	@FunctionDef(category = "conversion")
	public static String toText(Number value, String pattern, String languageTag) {
		DecimalFormat formatter = new DecimalFormat(pattern, new DecimalFormatSymbols(toLocale(languageTag)));
		return formatter.format(value);
	}

	@FunctionDef(category = "conversion")
	public static String toText(@Nullable LocalDate date) {
		if (date == null) return null;
		return date.format(defaultDateFormatter);
	}

	@FunctionDef(category = "conversion")
	public static String toText(@Nullable LocalDate date, String pattern) {
		if (date == null) return null;
		return tryToText(date, () -> date.format(DateTimeFormatter.ofPattern(pattern)));
	}

	@FunctionDef(category = "conversion")
	public static String toText(@Nullable LocalDate date, String pattern, String languageTag) {
		if (date == null) return null;
		return tryToText(date, () -> date.format(DateTimeFormatter.ofPattern(pattern, toLocale(languageTag))));
	}

	@FunctionDef(category = "conversion")
	public static String toText(LocalTime time) {
		return time.format(defaultTimeFormatter);
	}

	@FunctionDef(category = "conversion")
	public static String toText(LocalTime time, String pattern) {
		return tryToText(time, () -> time.format(DateTimeFormatter.ofPattern(pattern)));
	}

	@FunctionDef(category = "conversion")
	public static String toText(LocalTime time, String pattern, String languageTag) {
		return tryToText(time, () -> time.format(DateTimeFormatter.ofPattern(pattern, toLocale(languageTag))));
	}

	@FunctionDef(category = "conversion")
	public static String toText(OffsetDateTime dateTime) {
		return dateTime.format(defaultDateTimeFormatter);
	}

	@FunctionDef(category = "conversion")
	public static String toText(OffsetDateTime dateTime, String pattern) {
		return tryToText(dateTime, () -> dateTime.format(DateTimeFormatter.ofPattern(pattern)));
	}

	@FunctionDef(category = "conversion")
	public static String toText(OffsetDateTime dateTime, String pattern, String languageTag) {
		return tryToText(dateTime, () -> dateTime.format(DateTimeFormatter.ofPattern(pattern, toLocale(languageTag))));
	}

	@FunctionDef(category = "conversion")
	public static String toText(Object o) { return tryToText(o, () -> String.valueOf(o)); }

	/** Produce a proper error message for the toText functions. */
	private static  String tryToText(T value, Supplier f) {
		try {
			return f.get();
		} catch (Exception e) {
			throw inputError(value, toSnakeCase("toText"), "text value", e);
		}
	}

	@FunctionDef(category = "conversion")
	public static String filename(ContentRef contentRef) {
		return contentRef.getFilename();
	}

	@FunctionDef(category = "conversion")
	public static ContentRef toFile(Object object) {
		return anyToFile(object);
	}

	@FunctionDef(category = "conversion")
	public static List toFiles(List objects) {
		return objects.stream()
			.map(DefaultFunctions::anyToFile)
			.toList();
	}

	@FunctionDef(category = "conversion")
	public static List toFiles(Object... objects) {
		return Arrays.stream(objects)
			.map(DefaultFunctions::anyToFile)
			.toList();
	}

	private static ContentRef anyToFile(Object obj) {
		if (obj instanceof Document doc) {
			if (doc.getPublishedAt() == null) {
				LOG.warn("Returning content ref for unpublished document {}: {}", doc, doc.getContentRef());
			}
			return doc.getContentRef();
		}
		throw new UnsupportedOperationException("Cannot convert to file: " + obj);
	}

	@FunctionDef(category = "conversion")
	public static String anyToText(Object object) {
		return String.valueOf(object);
	}

	// === numeric functions === //

	private static final MathContext mathContext = MathContext.DECIMAL64;

	@FunctionDef(category = "numeric")
	public static long rounded(BigDecimal decimal) {
		return rounded(decimal, 0).longValue();
	}

	@FunctionDef(category = "numeric")
	public static BigDecimal rounded(BigDecimal decimal, long integer) {
		return rounded(decimal, integer, RoundingMode.HALF_UP);
	}

	@FunctionDef(category = "numeric")
	private static BigDecimal rounded(BigDecimal decimal, long integer, RoundingMode mode) {
		if (integer < 0)
			throw inputError(decimal, "rounded", "decimal");

		return decimal.setScale((int) integer, mode);
	}

	@FunctionDef(category = "numeric")
	public static long roundedDown(BigDecimal decimal) {
		return roundedDown(decimal, 0L).longValue();
	}

	@FunctionDef(category = "numeric")
	public static BigDecimal roundedDown(BigDecimal decimal, long integer) {
		return rounded(decimal, integer, RoundingMode.DOWN);
	}

	@FunctionDef(category = "numeric")
	public static long roundedUp(BigDecimal decimal) {
		return roundedUp(decimal, 0).longValue();
	}

	@FunctionDef(category = "numeric")
	public static BigDecimal roundedUp(BigDecimal decimal, long integer) {
		return decimal.setScale((int) integer, RoundingMode.UP);
	}

	@FunctionDef(category = "numeric")
	public static PeriodDuration rounded(PeriodDuration pd, String toUnit) {
		Period period = pd.getPeriod();
		Duration duration = pd.getDuration();

		switch (ChronoUnit.valueOf(toUnit.toUpperCase())) {
			case YEARS:   period = period.withMonths(0);
			case MONTHS:  period = period.withDays(0);
			case WEEKS:   period = period.withDays((period.getDays() / 7) * 7);
			case DAYS:    duration = duration.withSeconds(0);
			case HOURS:   duration = duration.withSeconds((duration.getSeconds() / 3600) * 3600);
			case MINUTES: duration = duration.withSeconds((duration.getSeconds() / 60) * 60);
			case SECONDS: duration = duration.withNanos(0);
			case NANOS:   return PeriodDuration.of(period, duration);
		}

		throw new IllegalArgumentException("Invalid unit: " + toUnit);
	}

	@FunctionDef(category = "numeric", strict = true)
	public static Long sum(List nums) {
		return nums.parallelStream().filter(Objects::nonNull).mapToLong(Long::longValue).sum();
	}

	@FunctionDef(category = "numeric", name = "sum", strict = true)
	public static BigDecimal decimalSum(List nums) {
		return nums.parallelStream().filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add);
	}

	@FunctionDef(category = "numeric")
	public static Long abs(Long number) {
		return Math.abs(number);
	}

	@FunctionDef(category = "numeric")
	public static BigDecimal abs(BigDecimal number) {
		return number.abs();
	}

	@FunctionDef(category = "numeric")
	public static BigDecimal sqrt(Long number) {
		return BigDecimalMath.sqrt(BigDecimal.valueOf(number), mathContext);
	}

	@FunctionDef(category = "numeric")
	public static BigDecimal sqrt(BigDecimal number) {
		return BigDecimalMath.sqrt(number, mathContext);
	}

	/** Both lower and upper bounds are inclusive. */
	@FunctionDef(category = "numeric")
	public static long randomNumber(long lowerBound, long upperBound) {
		Preconditions.checkArgument(upperBound > lowerBound);
		long range = upperBound - lowerBound;
		return (long) (random.nextDouble() * range + lowerBound);
	}

	@FunctionDef(category = "numeric")
	public static BigDecimal weightedAverage(List quantities, List weights) {
		Preconditions.checkArgument(quantities.size() == weights.size(), "Arguments `quantities` and `weights` must have the same size");

		BigDecimal totalWeight = BigDecimal.ZERO;
		BigDecimal totalQuantity = BigDecimal.ZERO;
		for (int i = 0; i < quantities.size(); i++) {
			BigDecimal quantity = quantities.get(i);
			BigDecimal weight = weights.get(i);
			totalWeight = totalWeight.add(quantity.multiply(weight));
			totalQuantity = totalQuantity.add(quantity);
		}

		return totalWeight.divide(totalQuantity, MathContext.DECIMAL128);
	}

	public static BigDecimal weightedAverageOptional(List quantities, List weights) {
		if (quantities.isEmpty() || weights.isEmpty()) return null;
		return weightedAverage(quantities, weights);
	}

	@FunctionDef(category = "numeric")
	public static BigDecimal truncatedWeightedSum(List quantities, List weights, BigDecimal truncation) {
		Preconditions.checkArgument(quantities.size() == weights.size(), "Arguments `quantities` and `weights` must have the same size");

		BigDecimal totalWeight = BigDecimal.ZERO;
		BigDecimal countedQuantity = BigDecimal.ZERO;

		for (int i = 0; i < quantities.size(); i++) {
			BigDecimal quantity = quantities.get(i);
			BigDecimal weight = weights.get(i);

			if (countedQuantity.compareTo(truncation) < 0) {
				boolean limitExceeded = (countedQuantity.add(quantity)).compareTo(truncation) > 0;
				BigDecimal truncatedQuantity = limitExceeded ? truncation.subtract(countedQuantity) : quantity;
				totalWeight = totalWeight.add(truncatedQuantity.multiply(weight));
				countedQuantity = countedQuantity.add(quantity);
			}
			else
				break;
		}

		return totalWeight;
	}

	public static BigDecimal truncatedWeightedSumOptional(List quantities, List weights, BigDecimal truncation) {
		if (quantities.isEmpty() || weights.isEmpty()) return null;
		return truncatedWeightedSum(quantities, weights, truncation);
	}

	// === textual functions === //

	@FunctionDef(category = "text")
	public static String capitalized(String text) {
		return StringUtils.capitalize(text);
	}

	@FunctionDef(category = "text")
	public static String capitalizedWords(String text) {
		return WordUtils.capitalize(text);
	}

	@FunctionDef(category = "text")
	public static String lowercase(String text) {
		return text.toLowerCase();
	}

	@FunctionDef(category = "text")
	public static String uppercase(String text) {
		return text.toUpperCase();
	}

	@FunctionDef(category = "text")
	public static String trimmed(String text) {
		return text.trim();
	}

	@FunctionDef(category = "text")
	public static String normalizedSpaces(String text) {
		return text.trim().replaceAll("\\s+"," ");
	}

	@FunctionDef(category = "text")
	public static String substituted(String basetext, String matchtext, String replacetext) {
		return toPattern(matchtext, "Pattern parameter of function substituted is not a valid regular expression.")
			.matcher(basetext).replaceAll(replacetext);
	}

	@FunctionDef(category = "text")
	public static String substitutedRegex(String basetext, String pattern, String replacetext) { // TODO: this is the same function as 'substituted'
		return substituted(basetext, pattern, replacetext);
	}

	private static Pattern toPattern(String regex, String errorMsg) {
		try {
			// verify the matching pattern first
			return Pattern.compile(regex);
		} catch (Exception e) {
			throw new IllegalArgumentException(errorMsg, e);
		}
	}

	@FunctionDef(category = "text", strict = true)
	public static String concat(Collection coll_texts) {
		return concat(coll_texts, "");
	}

	@FunctionDef(category = "text", strict = true)
	public static String concat(Collection coll_texts, String separator) {
		return coll_texts.stream().filter(Objects::nonNull).collect(Collectors.joining(separator));
	}

	@FunctionDef(category = "text")
	public static List split(String text, String separator) {
		String separatorRegex = Pattern.quote(separator);
		return Arrays.asList(text.split(separatorRegex));
	}

	@FunctionDef(category = "text")
	public static List splitRegex(String text, String separatorRegex) {
		return Arrays.asList(text.split(separatorRegex));
	}

	@FunctionDef(category = "text")
	public static long position(String baseText, String matchText) {
		long index = baseText.indexOf(matchText);
		return index < 0 ? 0 : index + 1;
	}

	@FunctionDef(category = "text")
	public static long positionRegex(String baseText, String pattern) {
		Pattern ptrn = toPattern(pattern, "Pattern parameter of function positionRegex is not a valid regular expression.");
		Matcher matcher = ptrn.matcher(baseText);
		return matcher.find() ? matcher.start() + 1 : 0;
	}

	@FunctionDef(category = "text")
	public static String substring(String baseText, long position1, long position2) {
		int nposition1 = (int) position1 - 1;
		int nposition2 = (int) position2;

		if (nposition1 < 0 || nposition1 > baseText.length())
			throw new IllegalArgumentException("Position parameter 'position1' for function substring out of bounds");

		if (nposition2 < 0 || nposition2 > baseText.length())
			throw new IllegalArgumentException("Position parameter 'position2' for function substring out of bounds");

		if (nposition1 > nposition2)
			throw new IllegalArgumentException("Position parameters for function substring out of order.");

		return baseText.substring(nposition1, nposition2);
	}

	@FunctionDef(category = "text")
	public static long length(String text) {
		return text.length();
	}

	@FunctionDef(category = "text")
	public static String encodePassword(String rawPassword) {
		return PasswordEncoderHolder.get().encode(rawPassword);
	}

	// not an official function, but used for processing placeholders within text literals
	private static final Pattern placeholderPattern = Pattern.compile("([\\\\]*)\\{([\\d]+)}");
	public static String interpolate(String input, Object... args) {
		// replace null arguments with an empty string
		List vars = Arrays.stream(argToArray(args))
			.map(i -> i == null ? "" : String.valueOf(i))
			.toList();

		// obtain all indexes of the form '{}' from the input
		Matcher matcher = placeholderPattern.matcher(input);
		// interpolate groups '{[0-9]+}' or uncomment groups that are commented
		StringBuilder sb = new StringBuilder();
		while (matcher.find()) {
			int escapes     = matcher.group(1).length();
			String indexStr = matcher.group(2);

			if (escapes % 2 == 0) {
				// interpolate
				int index = Integer.parseInt(indexStr);
				if (index >= vars.size())
					throw new ArrayIndexOutOfBoundsException(String.format("Invalid interpolation index %d is used. %d arguments were provided.", index, vars.size()));

				matcher.appendReplacement(sb, "\\\\".repeat(escapes) + vars.get(index));
			} else {
				// uncomment group
				matcher.appendReplacement(sb, "\\\\".repeat(escapes - 1) + "{" + indexStr + "}");
			}
		}
		matcher.appendTail(sb);

		return sb.toString();
	}

	// === temporal functions === //

	// the ability to override the current date and time is only intended for testing purpuses
	private static OffsetDateTime currentDateTimeOverride = null;
	private static Random random = new Random(System.currentTimeMillis());

	public static void setCurrentDateTime(OffsetDateTime override) {
		if (currentDateTimeOverride != null || override != null) {
			LOG.info("Setting current datetime override from {} to {}", currentDateTimeOverride, override);
		}
		currentDateTimeOverride = override;
		long seed = override != null ? millisecondsFromEpoch(override) : System.currentTimeMillis();
		random = new Random(seed);
	}

	@FunctionDef(category = "temporal")
	public static LocalDate currentDate() {
		return currentDateTimeOverride != null ? currentDateTimeOverride.toLocalDate() : LocalDate.now();
	}

	@FunctionDef(category = "temporal", strict = true)
	public static LocalDate currentDate(Object ignore) {
		return currentDate();
	}

	@FunctionDef(category = "temporal")
	public static LocalTime currentTime() {
		return currentDateTimeOverride != null ? currentDateTimeOverride.toLocalTime() : LocalTime.now();
	}

	@FunctionDef(category = "temporal", strict = true)
	public static LocalTime currentTime(Object ignore) {
		return currentTime();
	}

	// Deliberately not `currentDateTime`, since the SN function is `current_datetime`, not `current_date_time`.
	@FunctionDef(category = "temporal")
	public static OffsetDateTime currentDatetime() {
		return currentDateTimeOverride == null ? OffsetDateTime.now() : currentDateTimeOverride;
	}

	@FunctionDef(category = "temporal", strict = true)
	public static OffsetDateTime currentDatetime(Object ignore) {
		return currentDatetime();
	}

	@FunctionDef(category = "temporal")
	public static LocalDate datePart(OffsetDateTime dateTime) {
		return dateTime.toLocalDate();
	}

	@FunctionDef(category = "temporal")
	public static LocalTime timePart(OffsetDateTime dateTime) {
		return dateTime.toLocalTime();
	}

	@FunctionDef(category = "temporal")
	public static LocalTime timePart(OffsetDateTime dateTime, String zoneName) {
		ZoneId zoneId = ZoneId.of(zoneName);
		return dateTime.atZoneSameInstant(zoneId).toLocalTime();
	}

	@FunctionDef(category = "temporal")
	public static long dayOfMonth(OffsetDateTime dateTime) {
		return dateTime.getDayOfMonth();
	}

	@FunctionDef(category = "temporal")
	public static long dayOfMonth(LocalDate date) {
		return date.getDayOfMonth();
	}

	@FunctionDef(category = "temporal")
	public static long dayOfYear(OffsetDateTime dateTime) {
		return dateTime.getDayOfYear();
	}

	@FunctionDef(category = "temporal")
	public static long dayOfYear(LocalDate date) {
		return date.getDayOfYear();
	}

	@FunctionDef(category = "temporal")
	public static long weekOfYear(OffsetDateTime dateTime) {
		return weekOfYear(dateTime.toLocalDate());
	}

	private static final TemporalField weekField = WeekFields.ISO.weekOfWeekBasedYear(); // weekOfYear would return 0 instead of 52 on the threshold

	@FunctionDef(category = "temporal")
	public static long weekOfYear(LocalDate date) {
		return date.get(weekField);
	}

	@FunctionDef(category = "temporal")
	public static long monthOfYear(OffsetDateTime dateTime) {
		return monthOfYear(dateTime.toLocalDate());
	}

	@FunctionDef(category = "temporal")
	public static long monthOfYear(LocalDate date) {
		return date.getMonthValue();
	}

	@FunctionDef(category = "temporal")
	public static long quarterOfYear(OffsetDateTime dateTime) {
		return quarterOfYear(dateTime.toLocalDate());
	}

	@FunctionDef(category = "temporal")
	public static long quarterOfYear(LocalDate date) {
		return  ((monthOfYear(date) - 1) / 3) + 1;
	}

	@FunctionDef(category = "temporal")
	public static LocalDate dayOfWeek(Long weekInYearNr, Long year, String dayName) {
		DayOfWeek dayOfWeek = DayOfWeek.valueOf(dayName.toUpperCase());
		return YearWeek.of(year.intValue(), weekInYearNr.intValue()).atDay(dayOfWeek);
	}

	@FunctionDef(category = "temporal")
	public static LocalDate startOfWeek(Long weekInYearNr, Long year, String languageTag) {
		Locale locale = Locale.forLanguageTag(languageTag);
		DayOfWeek firstDayOfWeek = WeekFields.of(locale).getFirstDayOfWeek();
		return YearWeek.of(year.intValue(), weekInYearNr.intValue()).atDay(firstDayOfWeek);
	}

	@FunctionDef(category = "temporal")
	public static LocalDate endOfWeek(Long weekInYearNr, Long year, String languageTag) {
		Locale locale = Locale.forLanguageTag(languageTag);
		DayOfWeek firstDayOfWeek = WeekFields.of(locale).getFirstDayOfWeek();
		DayOfWeek lastDayOfWeek = DayOfWeek.of(((firstDayOfWeek.getValue() + 5) % 7) + 1);
		return YearWeek.of(year.intValue(), weekInYearNr.intValue()).atDay(lastDayOfWeek);
	}

	@FunctionDef(category = "temporal")
	public static boolean isWorkday(LocalDate day) {
		return day.getDayOfWeek() != DayOfWeek.SATURDAY
				&& day.getDayOfWeek() != DayOfWeek.SUNDAY;
	}

	@FunctionDef(category = "temporal")
	public static boolean isWorkday(LocalDate day, Collection exclude) {
		return day.getDayOfWeek() != DayOfWeek.SATURDAY
				&& day.getDayOfWeek() != DayOfWeek.SUNDAY
				&& !exclude.contains(day);
	}

	@FunctionDef(category = "temporal")
	public static long workdaysBetween(LocalDate start, LocalDate end) {
		if (start.isAfter(end)) {
			return -workdaysBetween(end, start);
		}
		LocalDate workday = start;
		long workdaysBetween = 0;
		while (workday.isBefore(end)) {
			workday = toFutureWorkday(workday.plusDays(1));
			workdaysBetween++;
		}
		return workdaysBetween;
	}

	@FunctionDef(category = "temporal")
	public static long workdaysBetween(LocalDate start, LocalDate end, Collection exclude) {
		if (start.isAfter(end)) {
			return -workdaysBetween(end, start, exclude);
		}
		LocalDate workday = start;
		long workdaysBetween = 0;
		while (workday.isBefore(end)) {
			workday = toFutureWorkday(workday.plusDays(1), exclude);
			workdaysBetween++;
		}
		return workdaysBetween;
	}

	private static LocalDate toFutureWorkday(LocalDate date) {
		LocalDate workday = date;
		while (!isWorkday(workday)) {
			workday = workday.plusDays(1);
		}
		return workday;
	}

	private static LocalDate toFutureWorkday(LocalDate date, Collection exclude) {
		LocalDate workday = date;
		while (!isWorkday(workday, exclude)) {
			workday = workday.plusDays(1);
		}
		return workday;
	}

	@FunctionDef(category = "temporal")
	public static LocalDate addWorkdays(LocalDate start, long daysToAdd) {
		LocalDate end = start;
		for (long i = 0; i < daysToAdd; i++) {
			end = toFutureWorkday(end.plusDays(1));
		}
		return end;
	}

	@FunctionDef(category = "temporal")
	public static LocalDate addWorkdays(LocalDate start, long daysToAdd, Collection exclude) {
		LocalDate end = start;
		for (long i = 0; i < daysToAdd; i++) {
			end = toFutureWorkday(end.plusDays(1), exclude);
		}
		return end;
	}

	private static LocalDate toPastWorkday(LocalDate date) {
		LocalDate workday = date;
		while (!isWorkday(workday)) {
			workday = workday.plusDays(-1);
		}
		return workday;
	}

	private static LocalDate toPastWorkday(LocalDate date, Collection exclude) {
		LocalDate workday = date;
		while (!isWorkday(workday, exclude)) {
			workday = workday.plusDays(-1);
		}
		return workday;
	}

	@FunctionDef(category = "temporal")
	public static LocalDate subtractWorkdays(LocalDate start, long daysToSubtract) {
		LocalDate end = start;
		for (long i = 0; i < daysToSubtract; i++) {
			end = toPastWorkday(end.plusDays(-1));
		}
		return end;
	}

	@FunctionDef(category = "temporal")
	public static LocalDate subtractWorkdays(LocalDate start, long daysToSubtract, Collection exclude) {
		LocalDate end = start;
		for (long i = 0; i < daysToSubtract; i++) {
			end = toPastWorkday(end.plusDays(-1), exclude);
		}
		return end;
	}

	@FunctionDef(category = "temporal")
	public static List cumulativeAddWorkdays(LocalDate start, List dayAmounts) {
		return cumulativeOperation(start, dayAmounts, DefaultFunctions::addWorkdays);
	}

	@FunctionDef(category = "temporal")
	public static List cumulativeAddWorkdays(LocalDate start, List dayAmounts, Collection exclude) {
		return cumulativeOperation(start, dayAmounts, (date, daysToAdd) -> addWorkdays(date, daysToAdd, exclude));
	}

	@FunctionDef(category = "temporal")
	public static List cumulativeSubtractWorkdays(LocalDate start, List dayAmounts) {
		return cumulativeOperation(start, dayAmounts, DefaultFunctions::subtractWorkdays);
	}

	@FunctionDef(category = "temporal")
	public static List cumulativeSubtractWorkdays(LocalDate start, List dayAmounts, Collection exclude) {
		return cumulativeOperation(start, dayAmounts, (date, daysToSubtract) -> subtractWorkdays(date, daysToSubtract, exclude));
	}

	@FunctionDef(category = "temporal")
	public static long millisecondsBetween(Temporal start, Temporal end) {
		assertRequireTimesOrDates(start, end, "millisecondsBetween");
		assertTimeRestriction(start, end, "millisecondsBetween", "milliseconds");
		return toBetween(start, end).toMillis();
	}

	@FunctionDef(category = "temporal")
	public static long secondsBetween(Temporal start, Temporal end) {
		assertRequireTimesOrDates(start, end, "secondsBetween");
		assertTimeRestriction(start, end, "secondsBetween", "seconds");
		return toBetween(start, end).getSeconds();
	}

	@FunctionDef(category = "temporal")
	public static long minutesBetween(Temporal start, Temporal end) {
		assertRequireTimesOrDates(start, end, "minutesBetween");
		assertTimeRestriction(start, end, "minutesBetween", "minutes");
		return toBetween(start, end).toMinutes();
	}

	@FunctionDef(category = "temporal")
	public static long hoursBetween(Temporal start, Temporal end) {
		assertRequireTimesOrDates(start, end, "hoursBetween");
		assertTimeRestriction(start, end, "hoursBetween", "hours");
		return toBetween(start, end).toHours();
	}

	@FunctionDef(category = "temporal")
	public static long daysBetween(Temporal start, Temporal end) {
		assertRequireDates(start, end, "daysBetween");
		return toBetween(start, end).toDays();
	}

	@FunctionDef(category = "temporal")
	public static long weeksBetween(Temporal start, Temporal end) {
		assertRequireDates(start, end, "weeksBetween");
		return toBetween(start, end).toDays() / 7;
	}

	@FunctionDef(category = "temporal")
	public static long monthsBetween(Temporal start, Temporal end) {
		assertRequireDates(start, end, "monthsBetween");
		return Months.between(start, end).getAmount();
	}

	@FunctionDef(category = "temporal")
	public static long yearsBetween(Temporal start, Temporal end) {
		assertRequireDates(start, end, "yearsBetween");
		return toBetween(start, end).toDays() / 365;
	}

	@FunctionDef(category = "temporal")
	public static long asMillis(OffsetDateTime dateTime) {
		return dateTime.toInstant().toEpochMilli();
	}

	@FunctionDef(category = "temporal")
	public static long asMillis(PeriodDuration pd) {
		if (!pd.getPeriod().isZero()) throw new IllegalArgumentException("Cannot convert this period to millis: " + pd);
		return pd.getDuration().toMillis();
	}

	// epoch starts at 01-01-1970 00:00:00 GMT (aka UTC)
	private static final ZoneId utcZoneId = ZoneId.of("UTC");

	@FunctionDef(category = "temporal")
	public static long millisecondsFromEpoch(LocalDate date) {
		return date.atStartOfDay(utcZoneId).toInstant().toEpochMilli();
	}

	@FunctionDef(category = "temporal")
	public static long millisecondsFromEpoch(OffsetDateTime datetime) {
		return datetime.toInstant().toEpochMilli();
	}

	@FunctionDef(category = "temporal")
	public static long daysInPeriod(PeriodDuration period) {
		return period.getPeriod().getDays() + period.getDuration().toDays();
	}

	@FunctionDef(category = "temporal")
	public static long weeksInPeriod(PeriodDuration period) {
		return daysInPeriod(period) / 7;
	}

	@FunctionDef(category = "temporal")
	public static long monthsInPeriod(PeriodDuration period) {
		return (period.getPeriod().getYears() * 12L + period.getPeriod().getMonths());
	}

	@FunctionDef(category = "temporal")
	public static long yearsInPeriod(PeriodDuration period) {
		return monthsInPeriod(period) / 12;
	}

	@FunctionDef(category = "temporal")
	public static long second(Temporal temporal) {
		return temporal.get(ChronoField.SECOND_OF_MINUTE);
	}

	@FunctionDef(category = "temporal")
	public static long minute(Temporal temporal) {
		return temporal.get(ChronoField.MINUTE_OF_HOUR);
	}

	@FunctionDef(category = "temporal")
	public static long hour(Temporal temporal) {
		return temporal.get(ChronoField.HOUR_OF_DAY);
	}

	@FunctionDef(category = "temporal")
	public static long day(Temporal temporal) {
		return temporal.get(ChronoField.DAY_OF_MONTH);
	}

	@FunctionDef(category = "temporal")
	public static long week(Temporal temporal) {
		return temporal.get(ChronoField.ALIGNED_WEEK_OF_YEAR);
	}

	@FunctionDef(category = "temporal")
	public static long month(Temporal temporal) {
		return temporal.get(ChronoField.MONTH_OF_YEAR);
	}

	@FunctionDef(category = "temporal")
	public static long year(Temporal temporal) {
		return temporal.get(ChronoField.YEAR);
	}

	private static final MathContext FOUR_POINTS = new MathContext(4, RoundingMode.HALF_UP);
	@FunctionDef(category = "temporal")
	public static BigDecimal timeshareQuota(List timespan, List> startStops) {
		if (startStops.isEmpty()) {
			return BigDecimal.valueOf(timespan.get(0).until(timespan.get(1), ChronoUnit.MINUTES));
		}
		BigDecimal total = BigDecimal.ZERO.setScale(FOUR_POINTS.getPrecision(), FOUR_POINTS.getRoundingMode());
		OffsetDateTime currentTime = timespan.get(0);
		List timePoints = startStops.stream()
			.flatMap(startStop -> Stream.of(new PointInTime(startStop.get(0), true), new PointInTime(startStop.get(1), false)))
			.sorted(Comparator.comparing(PointInTime::time))
			.toList();

		long runningTasks = 1 + timePoints.stream()
			.filter(pointInTime -> timespan.get(0).isAfter(pointInTime.time))
			.count();

		for (PointInTime pointInTime : timePoints.stream().filter(pointInTime -> !timespan.get(0).isAfter(pointInTime.time)).toList()) {
			if (pointInTime.start) {
				total = addToTotal(total, currentTime.until(pointInTime.time, ChronoUnit.MINUTES), runningTasks);
				runningTasks++;
				currentTime = pointInTime.time;
			} else if (pointInTime.time.isAfter(timespan.get(1))) {
				break;
			} else {
				total = addToTotal(total, currentTime.until(pointInTime.time, ChronoUnit.MINUTES), runningTasks);
				runningTasks--;
				currentTime = pointInTime.time;
			}
		}
		return addToTotal(total, currentTime.until(timespan.get(1), ChronoUnit.MINUTES), runningTasks);
	}

	// for backwards compatibility
	public static BigDecimal timeshare_quota(List timespan, List> startStops) {
		return timeshareQuota(timespan, startStops);
	}

	private static BigDecimal addToTotal(BigDecimal currentTotal, long minutes, long runningTasks) {
		return currentTotal.add(BigDecimal.valueOf(minutes).divide(BigDecimal.valueOf(runningTasks), FOUR_POINTS));
	}

	public record PointInTime(OffsetDateTime time, boolean start) {}

	// TODO semantics of these functions hasn't been properly defined yet
	/*
	public static long daysInPeriod(PeriodDuration period)    { return period.get(ChronoUnit.DAYS); }
	public static long secondsInPeriod(PeriodDuration period) { return period.get(ChronoUnit.SECONDS); }
	public static long minutesInPeriod(PeriodDuration period) { return period.get(ChronoUnit.MINUTES); }
	public static long hoursInPeriod(PeriodDuration period)   { return period.get(ChronoUnit.HOURS); }
	public static long weeksInPeriod(PeriodDuration period)   { return period.get(ChronoUnit.WEEKS); }
	public static long monthsInPeriod(PeriodDuration period)  { return period.get(ChronoUnit.MONTHS); }
	public static long yearsInPeriod(PeriodDuration period)   { return period.get(ChronoUnit.YEARS); }
	*/

	// === collection functions === //

	@FunctionDef(category = "collection")
	public static  long count(Collection collection) {
		return emptyIfNull(collection).size();
	}

	@FunctionDef(category = "collection")
	public static > T min(Collection collection) {
		return emptyIfNull(collection).stream().min(T::compareTo)
			.orElseThrow(() -> new IllegalArgumentException("Function min() is undefined for empty collections."));
	}

	@FunctionDef(category = "collection")
	public static > T max(Collection collection) {
		return emptyIfNull(collection).stream().max(T::compareTo)
			.orElseThrow(() -> new IllegalArgumentException("Function max() is undefined for empty collections."));
	}

	@FunctionDef(category = "collection")
	public static List range(long integer1, long integer2) {
		if (integer1 > integer2)
			return Lists.reverse(LongStream.rangeClosed(integer2, integer1).boxed().toList());
		else
			return LongStream.rangeClosed(integer1, integer2).boxed().toList();
	}

	@FunctionDef(category = "collection")
	public static  T elementAt(Collection collection, long position) {
		return elementAt(collection, position, "Position " + position + " for function element_at out of range.");
	}

	@FunctionDef(category = "collection")
	public static  T elementAt(Collection collection, long position, String message) {
		Collection safeCollection = emptyIfNull(collection);
		if (position <= 0 || position > safeCollection.size())
			throw new IllegalArgumentException(message);

		return Iterables.get(safeCollection, (int) position-1);
	}

	public static  Set copy(Set collection) {
		return new HashSet<>(collection);
	}

	public static  List copy(List collection) {
		return new ArrayList<>(collection);
	}

	@FunctionDef(category = "collection")
	public static  Collection copy(Collection collection) {
		if (collection instanceof Set) {
			return new HashSet<>(collection);
		}
		if (collection instanceof List) {
			return new ArrayList<>(collection);
		}
		throw new IllegalArgumentException("Unknown collection type: " + collection);
	}

	@FunctionDef(category = "collection")
	public static  List distinct(List collection) {
		// must support nulls, so cant use .toList() on stream
		return collection.stream().distinct().collect(Collectors.toList());
	}

	/**
	 * Flattens a collection of lists into a single list.
	 *
	 * @param collection the collection of lists to be flattened
	 * @param         the type of elements in the lists
	 * @return a single list containing all the elements from the input collection
	 */
	@FunctionDef(category = "collection")
	public static  List flattened(List> collection) {
		// must support nulls, so cant use .toList() on stream
		return collection.stream().filter(Objects::nonNull).flatMap(List::stream).collect(Collectors.toList());
	}

	@FunctionDef(category = "collection")
	public static  List withoutUndefined(List collection) {
		return collection.stream().filter(Objects::nonNull).toList();
	}

	private static  List cumulativeOperation(A start, List elements, BiFunction combiner) {
		List res = new ArrayList(elements.size());
		A current = start;
		for (B element : elements) {
			current = combiner.apply(current, element);
			res.add(current);
		}
		return res;
	}

	@FunctionDef(category = "collection")
	public static List cumulativePlus(Long start, List elements) {
		return cumulativeOperation(start, elements, Operators::sum);
	}

	@FunctionDef(category = "collection")
	public static List cumulativePlus(BigDecimal start, List elements) {
		return cumulativeOperation(start, elements, Operators::sum);
	}

	@FunctionDef(category = "collection")
	public static  List cumulativePlus(A start, List elements) {
		return cumulativeOperation(start, elements, Operators::add);
	}

	@FunctionDef(category = "collection")
	public static List cumulativeMinus(Long start, List elements) {
		return cumulativeOperation(start, elements, Operators::subtract);
	}

	@FunctionDef(category = "collection")
	public static List cumulativeMinus(BigDecimal start, List elements) {
		return cumulativeOperation(start, elements, Operators::subtract);
	}

	@FunctionDef(category = "collection")
	public static  List cumulativeMinus(A start, List elements) {
		return cumulativeOperation(start, elements, Operators::subtract);
	}

	// === process functions === //

	@FunctionDef(category = "process")
	public static String processInstanceId(Execution execution) {
		return execution.getProcessInstanceId();
	}

	@FunctionDef(category = "process")
	public static User currentUser(Execution execution) {
		return execution.getCurrentUser();
	}

	// === misc functions === //

	private static IdGenerator idGenerator;
	public static void setIdGenerator(IdGenerator g) { idGenerator = g; }

	@FunctionDef(category = "misc")
	public static String uniqueId() {
		return idGenerator.getNextId();
	}

	private static SequenceValueProducer sequenceValueProducer;
	public static void setSequenceValueProducer(SequenceValueProducer s) {
		sequenceValueProducer = s;
	}

	@FunctionDef(category = "misc")
	public static long sequence(String sequenceName) {
		if (sequenceValueProducer == null) throw new IllegalStateException("The sequenceValueProducer must be set");
		return sequenceValueProducer.nextValue(sequenceName);
	}

	@FunctionDef(category = "misc")
	public static DisplayedAs displayedAs(String format, Object value) {
		return new DisplayedAs(format, value);
	}

	@FunctionDef(category = "misc")
	public static String escaped(String format, String value) {
		return switch (format.toLowerCase()) {
			case "html" -> StringEscapeUtils.escapeHtml4(value);
			default     -> throw new UnsupportedOperationException("Escaping format '" + format + "' is not yet supported");
		};
	}

	@FunctionDef(category = "misc")
	public static long elementIndex(Execution execution, String elementName) {
		return execution.getElementIndex(elementName);
	}

	@SneakyThrows
	@FunctionDef(category = "misc")
	public static long sleep(long millis) {
		Thread.sleep(millis);
		return millis;
	}

	// NOTE this function exists mainly for testing purposes
	@FunctionDef(category = "misc")
	public static BigDecimal pi() {
		// see https://www.piday.org/million/
		return new BigDecimal("3.141592653589793238462643383279502884197");
	}

	// === deprecated functions ===

	/**
	 * @return null if collection is empty.
	 * @throws IllegalArgumentException if collection has more than 1 element.
	 */
	@Deprecated // use Operators.only
	public static  T only(Collection collection) {
		if (collection == null || collection.isEmpty()) return null;
		if (collection.size() != 1) throw new IllegalArgumentException("The collection must have exactly 1 element, but it has " + collection.size());
		return collection.iterator().next();
	}

	/**
	 * @return null if collection is empty.
	 */
	@Deprecated // use Operators.first
	public static  T first(Collection collection) {
		if (collection == null || collection.isEmpty()) return null;
		return collection.iterator().next();
	}

	/**
	 * @return null if collection is empty.
	 */
	@Deprecated // use Operators.last
	public static  T last(Collection collection) {
		if (collection == null || collection.isEmpty()) return null;
		return Iterables.getLast(collection);
	}

	// === supporting methods ===

	public static  T error(String msg, String location) {
		String message = StringUtils.nonEmpty(msg) ? msg : "An explicit error occurred in the specification";
		throw new SpecException(message, location);
	}

	public static boolean errorB(String msg, String location) {
		String message = StringUtils.nonEmpty(msg) ? msg : "An explicit error occurred in the specification";
		throw new SpecException(message, location);
	}

	/** convert sql 'like' regex to java regex */
	public static String likeToRegex(String likeRegex) {
		HashMap wildcards = new HashMap<>(Map.of(
			"_", ".",
			"%", ".*"
		));
		return patternToRegex(likeRegex, wildcards);
	}

	/** convert a pattern to java regex */
	private static String patternToRegex(String pattern, HashMap wildcards) {
		if (pattern.isEmpty())
			return pattern;
		else if (wildcards.isEmpty())
			return Pattern.quote(pattern);
		else {
			Map.Entry entry = wildcards.entrySet().iterator().next();
			String regex       = entry.getKey();
			String replacement = entry.getValue();
			wildcards.remove(regex, replacement);

			// interpret the given 'pattern' and replace any special 'regex' symbols with 'replacement'
			String noEscape = "(? part.replaceAll(Pattern.quote("\\" + regex), regex))
				// interpret parts with other remaining regex
				.map(part -> patternToRegex(part, new HashMap<>(wildcards)))
				// insert replacement for regex
				.collect(Collectors.joining(replacement));
		}
	}

	/** use 'hard' spaces, an remove preceding '+' sign. */
	private static String normalizeNumber(String value) {
		return value
			// insert 'hard' spaces. The nf parser cannot handle others.
			.replace(' ', '\u00a0')
			// remove preceding '+'. The nf parser cannot handle it.
			.replaceAll("\\s*\\+", "");
	}

	/** Compute duration between two temporals. */
	private static Duration toBetween(Temporal start, Temporal end) {
		if (start instanceof LocalDate localStartDate) start = OffsetDateTime.of(localStartDate, LocalTime.MIDNIGHT, ZoneOffset.UTC);
		if (end instanceof LocalDate localEndDate) end = OffsetDateTime.of(localEndDate, LocalTime.MIDNIGHT, ZoneOffset.UTC);
		return Duration.between(start, end);
		//return Duration.between(toDateTime(start), toDateTime(end));
	}

	// normalize varargs. varargs == null translates to an array with one element, namely null.
	private static Object[] argToArray(Object... args) {
		if (args == null)
			return new Object[]{ null };
		else
			return args;
	}

	private static  Collection emptyIfNull(Collection collection) {
		return collection == null ? Collections.emptyList() : collection;
	}

	// ======================= null safe variants =========================

	public static Boolean toBooleanOptional(Boolean optionalPredicate)        { return optional(optionalPredicate, DefaultFunctions::toBoolean); }
	public static Boolean toBooleanOptional(Long value)                       { return optional(value, DefaultFunctions::toBoolean); }
	public static Boolean toBooleanOptional(String value)                     { return optional(value, DefaultFunctions::toBoolean); }
	public static Boolean toBooleanOptional(String value, String languageTag) { return optional(value, languageTag, DefaultFunctions::toBoolean); }

	public static BigDecimal toDecimalOptional(Long value)                       { return optional(value, DefaultFunctions::toDecimal); }
	public static BigDecimal toDecimalOptional(String value)                     { return optional(value, DefaultFunctions::toDecimal); }
	public static BigDecimal toDecimalOptional(String value, String languageTag) { return optional(value, languageTag, DefaultFunctions::toDecimal); }

	public static Long toIntegerOptional(String value)                     { return optional(value, DefaultFunctions::toInteger); }
	public static Long toIntegerOptional(String value, String languageTag) { return optional(value, languageTag, DefaultFunctions::toInteger); }

	public static LocalDate toDateOptional(String dateText)                                                  { return optional(dateText, DefaultFunctions::toDate); }
	public static LocalDate toDateOptional(String dateText, String pattern, String languageTag)              { return optional(dateText, pattern, languageTag, DefaultFunctions::toDate); }
	public static LocalTime toTimeOptional(String timeText)                                                  { return optional(timeText, DefaultFunctions::toTime); }
	public static LocalTime toTimeOptional(String timeText, String pattern)                                  { return optional(timeText, pattern, DefaultFunctions::toTime); }
	public static OffsetDateTime toDatetimeOptional(String dateTimeText)                                     { return optional(dateTimeText, DefaultFunctions::toDatetime); }
	public static OffsetDateTime toDatetimeOptional(String dateTimeText, String pattern, String languageTag) { return optional(dateTimeText, pattern, languageTag, DefaultFunctions::toDatetime); }
	public static OffsetDateTime toDatetimeOptional(LocalDate date, LocalTime time, String zoneName)         { return optional(date, time, zoneName, DefaultFunctions::toDatetime); }
	public static PeriodDuration toPeriodOptional(String periodText)                                         { return optional(periodText, DefaultFunctions::toPeriod); }
	public static PeriodDuration toPeriodOptional(String periodText, String languageTag)                     { return optional(periodText, languageTag, DefaultFunctions::toPeriod); }

	public static String toTextOptional(PeriodDuration period)                                       { return optional(period, DefaultFunctions::toText); }
	public static String toTextOptional(PeriodDuration value, String languageTag)                    { return optional(value, languageTag, DefaultFunctions::toText); }
	public static String toTextOptional(PeriodDuration value, String languageTag, String format)     { return optional(value, languageTag, format, DefaultFunctions::toText); }
	public static String toTextOptional(Boolean value)                                               { return optional(value, DefaultFunctions::toText); }
	public static String toTextOptional(Boolean value, String languageTag)                           { return optional(value, languageTag, DefaultFunctions::toText); }
	public static String toTextOptional(Number value)                                                { return optional(value, DefaultFunctions::toText); }
	public static String toTextOptional(Number value, String pattern)                                { return optional(value, pattern, DefaultFunctions::toText); }
	public static String toTextOptional(Number value, String pattern, String languageTag)            { return optional(value, pattern, languageTag, DefaultFunctions::toText); }
	public static String toTextOptional(LocalDate date)                                              { return optional(date, DefaultFunctions::toText); }
	public static String toTextOptional(LocalDate date, String pattern)                              { return optional(date, pattern, DefaultFunctions::toText); }
	public static String toTextOptional(LocalDate date, String pattern, String languageTag)          { return optional(date, pattern, languageTag, DefaultFunctions::toText); }
	public static String toTextOptional(LocalTime time)                                              { return optional(time, DefaultFunctions::toText); }
	public static String toTextOptional(LocalTime time, String pattern)                              { return optional(time, pattern, DefaultFunctions::toText); }
	public static String toTextOptional(LocalTime time, String pattern, String languageTag)          { return optional(time, pattern, languageTag, DefaultFunctions::toText); }
	public static String toTextOptional(OffsetDateTime dateTime)                                     { return optional(dateTime, DefaultFunctions::toText); }
	public static String toTextOptional(OffsetDateTime dateTime, String pattern)                     { return optional(dateTime, pattern, DefaultFunctions::toText); }
	public static String toTextOptional(OffsetDateTime dateTime, String pattern, String languageTag) { return optional(dateTime, pattern, languageTag, DefaultFunctions::toText); }
	public static String toTextOptional(Object o)                                                    { return optional(o, DefaultFunctions::toText); }
	public static ContentRef toFileOptional(Object o)                                                { return optional(o, DefaultFunctions::toFile); }

	public static Long roundedOptional(BigDecimal decimal)                            { return optional(decimal, DefaultFunctions::rounded); }
	public static BigDecimal roundedOptional(BigDecimal decimal, Long integer)        { return optional(decimal, integer, DefaultFunctions::rounded); }
	public static BigDecimal roundedDownOptional(BigDecimal decimal, Integer integer) { return optional(decimal, integer, (d, i) -> roundedDownOptional(d, Long.valueOf(i))); }
	public static BigDecimal roundedDownOptional(BigDecimal decimal, Long integer)    { return optional(decimal, integer, DefaultFunctions::roundedDown); }
	public static Long roundedUpOptional(BigDecimal decimal)                          { return optional(decimal, DefaultFunctions::roundedUp); }
	public static BigDecimal roundedUpOptional(BigDecimal decimal, Long integer)      { return optional(decimal, integer, DefaultFunctions::roundedUp); }

	public static String capitalizedOptional(String text)      { return optional(text, DefaultFunctions::capitalized); }
	public static String capitalizedWordsOptional(String text) { return optional(text, DefaultFunctions::capitalizedWords); }
	public static String lowercaseOptional(String text)        { return optional(text, DefaultFunctions::lowercase); }
	public static String uppercaseOptional(String text)        { return optional(text, DefaultFunctions::uppercase); }
	public static String trimmedOptional(String text)          { return optional(text, DefaultFunctions::trimmed); }
	public static String normalizedSpacesOptional(String text) { return optional(text, DefaultFunctions::normalizedSpaces); }

	public static String substitutedOptional(String basetext, String matchtext, String replacetext) {
		return optional(basetext, matchtext, replacetext, DefaultFunctions::substituted); }

	public static String substitutedRegexOptional(String basetext, String pattern, String replacetext) { // TODO: this is the same function as 'substituted'
		return substitutedOptional(basetext, pattern, replacetext);
	}

	public static Long sumOptional(List nums)                    { return optional(nums, DefaultFunctions::sum); }
	public static BigDecimal decimalSumOptional(List nums) { return optional(nums, DefaultFunctions::decimalSum); }
	public static Long absOptional(Long number)                        { return optional(number, DefaultFunctions::abs); }
	public static BigDecimal absOptional(BigDecimal number)            { return optional(number, DefaultFunctions::abs); }
	public static BigDecimal sqrtOptional(Long number)                 { return optional(number, DefaultFunctions::sqrt); }
	public static BigDecimal sqrtOptional(BigDecimal number)           { return optional(number, DefaultFunctions::sqrt); }

	public static String concatOptional(Collection coll_texts)                   { return optional(coll_texts, DefaultFunctions::concat); }
	public static String concatOptional(Collection coll_texts, String separator) { return optional(coll_texts, separator, DefaultFunctions::concat); }
	public static List splitOptional(String text, String separator)              { return optional(text, separator, DefaultFunctions::split); }

	public static Long positionOptional(String baseText, String matchText)               { return optional(baseText, matchText, DefaultFunctions::position); }
	public static Long positionRegexOptional(String baseText, String pattern)            { return optional(baseText, pattern, DefaultFunctions::positionRegex); }

	public static String substringOptional(String baseText, Long position1, Long position2) { return optional(baseText, position1, position2, DefaultFunctions::substring); }
	public static String encodePasswordOptional(String rawPassword)                         { return optional(rawPassword, DefaultFunctions::encodePassword); }
	public static Long lengthOptional(String text)                                          { return optional(text, DefaultFunctions::length); }
	public static String interpolateOptional(String input, Object... args)                  { return optional(input, text -> interpolate(text, args)); } // NOTE: intentionally not checking 'args' here!

	public static  T errorOptional(String msg, String location)             { return error(msg, location); }
	public static DisplayedAs displayedAsOptional(String format, Object value) { return optional(value, v -> displayedAs(format, v)); }
	public static String escapedOptional(String format, String value)          { return optional(value, v -> escaped(format, v)); }

	// === temporal functions === //

	public static LocalDate datePartOptional(OffsetDateTime dateTime) { return optional(dateTime, DefaultFunctions::datePart); }
	public static LocalTime timePartOptional(OffsetDateTime dateTime) { return optional(dateTime, DefaultFunctions::timePart); }
	public static LocalTime timePartOptional(OffsetDateTime dateTime, String zoneName) { return optional(dateTime, zoneName, DefaultFunctions::timePart); }

	public static Long dayOfMonthOptional(OffsetDateTime dateTime)    { return optional(dateTime, DefaultFunctions::dayOfMonth); }
	public static Long dayOfMonthOptional(LocalDate date)             { return optional(date, DefaultFunctions::dayOfMonth); }
	public static Long dayOfYearOptional(OffsetDateTime dateTime)     { return optional(dateTime, DefaultFunctions::dayOfYear); }
	public static Long dayOfYearOptional(LocalDate date)              { return optional(date, DefaultFunctions::dayOfYear); }
	public static Long weekOfYearOptional(OffsetDateTime date)        { return optional(date, DefaultFunctions::weekOfYear); }
	public static Long weekOfYearOptional(LocalDate date)             { return optional(date, DefaultFunctions::weekOfYear); }
	public static Long monthOfYearOptional(OffsetDateTime dateTime)   { return optional(dateTime, DefaultFunctions::monthOfYear); }
	public static Long monthOfYearOptional(LocalDate date)            { return optional(date, DefaultFunctions::monthOfYear); }
	public static Long quarterOfYearOptional(OffsetDateTime dateTime) { return optional(dateTime, DefaultFunctions::quarterOfYear); }
	public static Long quarterOfYearOptional(LocalDate date)          { return optional(date, DefaultFunctions::quarterOfYear); }

	public static LocalDate dayOfWeekOptional(Long weekInYearNr, Long year, String dayName)       { return optional(weekInYearNr, year, dayName, DefaultFunctions::dayOfWeek); }
	public static LocalDate startOfWeekOptional(Long weekInYearNr, Long year, String languageTag) { return optional(weekInYearNr, year, languageTag, DefaultFunctions::startOfWeek); }
	public static LocalDate endOfWeekOptional(Long weekInYearNr, Long year, String languageTag)   { return optional(weekInYearNr, year, languageTag, DefaultFunctions::endOfWeek); }

	public static boolean isWorkdayOptional(LocalDate day)                                                                          { return optional(day, DefaultFunctions::isWorkday); }
	public static boolean isWorkdayOptional(LocalDate day, Collection excl)                                              { return optional(day, excl, DefaultFunctions::isWorkday); }
	public static long workdaysBetweenOptional(LocalDate start, LocalDate end)                                                      { return optional(start, end, DefaultFunctions::workdaysBetween); }
	public static long workdaysBetweenOptional(LocalDate start, LocalDate end, Collection excl)                          { return optional(start, end, excl, DefaultFunctions::workdaysBetween); }
	public static LocalDate addWorkdaysOptional(LocalDate day, long n)                                                              { return optional(day, n, DefaultFunctions::addWorkdays); }
	public static LocalDate addWorkdaysOptional(LocalDate day, long n, Collection excl)                                  { return optional(day, n, excl, DefaultFunctions::addWorkdays); }
	public static LocalDate subtractWorkdaysOptional(LocalDate day, long n)                                                         { return optional(day, n, DefaultFunctions::subtractWorkdays); }
	public static LocalDate subtractWorkdaysOptional(LocalDate day, long n, Collection excl)                             { return optional(day, n, excl, DefaultFunctions::subtractWorkdays); }
	public static List cumulativeAddWorkdaysOptional(LocalDate day, List amounts)                                  { return optional(day, amounts, DefaultFunctions::cumulativeAddWorkdays); }
	public static List cumulativeAddWorkdaysOptional(LocalDate day, List amounts, Collection excl)      { return optional(day, amounts, excl, DefaultFunctions::cumulativeAddWorkdays); }
	public static List cumulativeSubtractWorkdaysOptional(LocalDate day, List amounts)                             { return optional(day, amounts, DefaultFunctions::cumulativeSubtractWorkdays); }
	public static List cumulativeSubtractWorkdaysOptional(LocalDate day, List amounts, Collection excl) { return optional(day, amounts, excl, DefaultFunctions::cumulativeSubtractWorkdays); }

	public static Long millisecondsBetweenOptional(Temporal start, Temporal end) { return optional(start, end, DefaultFunctions::millisecondsBetween); }
	public static Long secondsBetweenOptional(Temporal start, Temporal end)      { return optional(start, end, DefaultFunctions::secondsBetween); }
	public static Long minutesBetweenOptional(Temporal start, Temporal end)      { return optional(start, end, DefaultFunctions::minutesBetween); }
	public static Long hoursBetweenOptional(Temporal start, Temporal end)        { return optional(start, end, DefaultFunctions::hoursBetween); }
	public static Long daysBetweenOptional(Temporal start, Temporal end)         { return optional(start, end, DefaultFunctions::daysBetween); }
	public static Long weeksBetweenOptional(Temporal start, Temporal end)        { return optional(start, end, DefaultFunctions::weeksBetween); }
	public static Long monthsBetweenOptional(Temporal start, Temporal end)       { return optional(start, end, DefaultFunctions::monthsBetween); }
	public static Long yearsBetweenOptional(Temporal start, Temporal end)        { return optional(start, end, DefaultFunctions::yearsBetween); }

	public static Long asMillisOptional(OffsetDateTime dateTime)              { return optional(dateTime, DefaultFunctions::asMillis); }
	public static Long asMillisOptional(PeriodDuration pd)                    { return optional(pd, DefaultFunctions::asMillis); }
	public static Long millisecondsFromEpochOptional(LocalDate date)          { return optional(date, DefaultFunctions::millisecondsFromEpoch); }
	public static Long millisecondsFromEpochOptional(OffsetDateTime datetime) { return optional(datetime, DefaultFunctions::millisecondsFromEpoch); }

	public static Long daysInPeriodOptional(PeriodDuration period)   { return optional(period, DefaultFunctions::daysInPeriod); }
	public static Long weeksInPeriodOptional(PeriodDuration period)  { return optional(period, DefaultFunctions::weeksInPeriod); }
	public static Long monthsInPeriodOptional(PeriodDuration period) { return optional(period, DefaultFunctions::monthsInPeriod); }
	public static Long yearsInPeriodOptional(PeriodDuration period)  { return optional(period, DefaultFunctions::yearsInPeriod); }

	public static Long secondOptional(Temporal temporal) { return optional(temporal, DefaultFunctions::second); }
	public static Long minuteOptional(Temporal temporal) { return optional(temporal, DefaultFunctions::minute); }
	public static Long hourOptional(Temporal temporal)   { return optional(temporal, DefaultFunctions::hour); }
	public static Long dayOptional(Temporal temporal)    { return optional(temporal, DefaultFunctions::day); }
	public static Long weekOptional(Temporal temporal)   { return optional(temporal, DefaultFunctions::week); }
	public static Long monthOptional(Temporal temporal)  { return optional(temporal, DefaultFunctions::month); }
	public static Long yearOptional(Temporal temporal)   { return optional(temporal, DefaultFunctions::year); }

	public static > T minOptional(Collection collection) { return optional(collection, DefaultFunctions::min); }
	public static > T maxOptional(Collection collection) { return optional(collection, DefaultFunctions::max); }

	public static List rangeOptional(Long integer1, Long integer2) { return optional(integer1, integer2, DefaultFunctions::range); }
}