/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.nifi.attribute.expression.language;

import org.apache.commons.lang3.StringUtils;
import org.apache.nifi.attribute.expression.language.Query.Range;
import org.apache.nifi.attribute.expression.language.evaluation.NumberQueryResult;
import org.apache.nifi.attribute.expression.language.evaluation.QueryResult;
import org.apache.nifi.attribute.expression.language.exception.AttributeExpressionLanguageException;
import org.apache.nifi.attribute.expression.language.exception.AttributeExpressionLanguageParsingException;
import org.apache.nifi.expression.AttributeExpression;
import org.apache.nifi.expression.AttributeExpression.ResultType;
import org.apache.nifi.flowfile.FlowFile;
import org.apache.nifi.parameter.Parameter;
import org.apache.nifi.parameter.ParameterDescriptor;
import org.apache.nifi.parameter.ParameterLookup;
import org.apache.nifi.registry.VariableRegistry;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;

import java.io.BufferedInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.Reader;
import java.text.SimpleDateFormat;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.Arrays;
import java.util.Calendar;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.TimeUnit;

import static java.lang.Double.NEGATIVE_INFINITY;
import static java.lang.Double.NaN;
import static java.lang.Double.POSITIVE_INFINITY;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.greaterThan;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

public class TestQuery {

    // Address book JsonPath constants
    public static final String ADDRESS_BOOK_JSON_PATH_FIRST_NAME = "${json:jsonPath('$.firstName')}";
    public static final String ADDRESS_BOOK_JSON_PATH_LAST_NAME = "${json:jsonPath('$.lastName')}";
    public static final String ADDRESS_BOOK_JSON_PATH_AGE = "${json:jsonPath('$.age')}";
    public static final String ADDRESS_BOOK_JSON_PATH_VOTER = "${json:jsonPath('$.voter')}";
    public static final String ADDRESS_BOOK_JSON_PATH_ADDRESS_POSTAL_CODE = "${json:jsonPath('$.address.postalCode')}";
    public static final String ADDRESS_BOOK_JSON_PATH_PHONE_NUMBERS_TYPE_HOME_NUMBER = "${json:jsonPath(\"$.phoneNumbers[?(@.type=='home')].number\")}";
    public static final String ADDRESS_BOOK_JSON_PATH_PHONE_NUMBERS_TYPE_OFFICE_NUMBER = "${json:jsonPath(\"$.phoneNumbers[?(@.type=='office')].number\")}";
    public static final String ADDRESS_BOOK_JSON_PATH_HEIGHT = "${json:jsonPath('$.height')}";
    public static final String ADDRESS_BOOK_JSON_PATH_EMPTY = "";

    private static final List<String> phoneBookAttributes = Arrays.asList(
            ADDRESS_BOOK_JSON_PATH_FIRST_NAME,
            ADDRESS_BOOK_JSON_PATH_LAST_NAME,
            ADDRESS_BOOK_JSON_PATH_AGE,
            ADDRESS_BOOK_JSON_PATH_VOTER,
            ADDRESS_BOOK_JSON_PATH_ADDRESS_POSTAL_CODE,
            ADDRESS_BOOK_JSON_PATH_PHONE_NUMBERS_TYPE_HOME_NUMBER,
            ADDRESS_BOOK_JSON_PATH_PHONE_NUMBERS_TYPE_OFFICE_NUMBER
    );

    @Test
    public void testCompilation() {
        assertInvalid("${attr:uuid()}");
        assertInvalid("${attr:indexOf(length())}");
        assertValid("${UUID()}");
        assertInvalid("${UUID():nextInt()}");
        assertValid("${attr:UUID3('94c09378-43a6-11ea-8bcc-acde48001122')}");
        assertValid("${attr:UUID5('94c09378-43a6-11ea-8bcc-acde48001122')}");
        assertInvalid("${UUID3('94c09378-43a6-11ea-8bcc-acde48001122', attr)}");
        assertInvalid("${UUID5('94c09378-43a6-11ea-8bcc-acde48001122', attr)}");
        assertValid("${nextInt()}");
        assertValid("${now():format('yyyy/MM/dd')}");
        assertInvalid("${attr:times(3)}");
        assertValid("${attr:toNumber():multiply(3)}");
        assertValid("${hostname()}");
        assertValid("${literal(3)}");
        assertValid("${random()}");
        assertValid("${getStateValue('the_count')}");
        assertValid("${attr:padLeft(10, '#')}");
        assertValid("${attr:padRight(10, '#')}");
        assertValid("${attr:padLeft(10)}");
        assertValid("${attr:padRight(10)}");
    }


    @Test
    public void testPrepareWithEscapeChar() {
        final Map<String, String> variables = Collections.singletonMap("foo", "bar");

        assertEquals("bar${foo}$bar", Query.prepare("${foo}$${foo}$$${foo}").evaluateExpressions(new StandardEvaluationContext(variables), null));

        final PreparedQuery onlyEscapedQuery = Query.prepare("$${foo}");
        final String onlyEscapedEvaluated = onlyEscapedQuery.evaluateExpressions(new StandardEvaluationContext(variables), null);
        assertEquals("${foo}", onlyEscapedEvaluated);

        final PreparedQuery mixedQuery = Query.prepare("${foo}$${foo}");
        final String mixedEvaluated = mixedQuery.evaluateExpressions(new StandardEvaluationContext(variables), null);
        assertEquals("bar${foo}", mixedEvaluated);

        final PreparedQuery multipleEscapedQuery = Query.prepare("$${foo}$${bar}");
        final String multipleEscapedEvaluated = multipleEscapedQuery.evaluateExpressions(new StandardEvaluationContext(variables), null);
        assertEquals("${foo}${bar}", multipleEscapedEvaluated);

        final PreparedQuery multipleEscapedWithTextQuery = Query.prepare("foo$${foo}bar$${bar}");
        final String multipleEscapedWithTextEvaluated = multipleEscapedWithTextQuery.evaluateExpressions(new StandardEvaluationContext(variables), null);
        assertEquals("foo${foo}bar${bar}", multipleEscapedWithTextEvaluated);

        final PreparedQuery multipleMixedQuery = Query.prepare("foo${foo}$${foo}bar${bar}$${bar}");
        final String multipleMixedEvaluated = multipleMixedQuery.evaluateExpressions(new StandardEvaluationContext(variables), null);
        assertEquals("foobar${foo}bar${bar}", multipleMixedEvaluated);
    }

    private void assertValid(final String query) {
        assertDoesNotThrow(() -> Query.compile(query));
    }

    private void assertInvalid(final String query) {
        assertThrows(Exception.class, () -> Query.compile(query));
    }

    @Test
    public void testIsValidExpression() {
        Query.validateExpression("${abc:substring(${xyz:length()})}", false);
        Query.isValidExpression("${now():format('yyyy-MM-dd')}");

        assertThrows(AttributeExpressionLanguageParsingException.class, () -> Query.validateExpression("$${attr}", false));

        Query.validateExpression("$${attr}", true);

        Query.validateExpression("${filename:startsWith('T8MTXBC')\n"
                + ":or( ${filename:startsWith('C4QXABC')} )\n"
                + ":or( ${filename:startsWith('U6CXEBC')} )"
                + ":or( ${filename:startsWith('KYM3ABC')} )}", false);
    }

    @Test
    public void testStringEL() {
        final Map<String, String> attrs = new HashMap<>();
        attrs.put("employee.gender", "male  ");
        attrs.put("employee.name", "Harry Potter");
        attrs.put("id", "1234");
        attrs.put("sql.query", "SELECT * FROM table WHERE ID = ${id}");

        String query = "${sql.query:evaluateELString()}";
        String query1 = "${employee.name:evaluateELString()}";
        String query2 = "${employee.name:evaluateELString():toUpper()}";
        String query3 = "${employee.gender:trim():evaluateELString()}";

        verifyEquals(query, attrs, "SELECT * FROM table WHERE ID = 1234");
        verifyEquals(query1, attrs, "Harry Potter");
        verifyEquals(query2, attrs, "HARRY POTTER");
        verifyEquals(query3, attrs, "male");
    }

    @Test
    public void testEvaluateELStringNonSensitiveParameters() {
        final String value = "value";
        final String query = "${variable:evaluateELString()}";

        final Map<String, String> variables = Collections.singletonMap("variable", "#{parameter}");
        final Map<String, String> parameters = Collections.singletonMap("parameter", value);

        final MapParameterLookup parameterLookup = new MapParameterLookup(parameters);
        final EvaluationContext evaluationContext = new StandardEvaluationContext(variables, Collections.emptyMap(), parameterLookup);

        final String evaluated = Query.prepare(query).evaluateExpressions(evaluationContext, null);

        assertEquals(StringUtils.EMPTY, evaluated);
    }

    @Test
    public void testEvaluateELStringSensitiveParameters() {
        final String parameterName = "parameter";
        final String parameterRef = String.format("#{%s}", parameterName);
        final String value = "value";
        final String query = "${variable:evaluateELString()}";

        final Map<String, String> variables = Collections.singletonMap("variable", parameterRef);

        final ParameterLookup parameterLookup = mock(ParameterLookup.class);
        final ParameterDescriptor parameterDescriptor = new ParameterDescriptor.Builder().name(parameterName).sensitive(true).build();
        final Parameter parameter = new Parameter(parameterDescriptor, value);
        when(parameterLookup.getParameter(eq(parameterName))).thenReturn(Optional.of(parameter));
        when(parameterLookup.isEmpty()).thenReturn(false);

        final EvaluationContext evaluationContext = new StandardEvaluationContext(variables, Collections.emptyMap(), parameterLookup);

        final String evaluated = Query.prepare(query).evaluateExpressions(evaluationContext, null);

        assertEquals(StringUtils.EMPTY, evaluated);
    }

    @Test
    public void testCompileEmbedded() {
        final String expression = "${x:equals( ${y} )}";

        final Map<String, String> attributes = new HashMap<>();
        attributes.put("x", "x");
        attributes.put("y", "x");
        final String result = Query.evaluateExpressions(expression, attributes, null);
        assertEquals("true", result);

        Query.validateExpression(expression, false);
    }

    @Test
    public void testEscape() {
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("abc", "xyz");
        attributes.put("xyz", "hello");
        attributes.put("$xyz", "good-bye");
        attributes.put("${abc}", "good-bye");

        assertEquals("$$xyz", evaluateQueryForEscape("$$$$${abc}", attributes));
        assertEquals("xyz", evaluateQueryForEscape("${abc}", attributes));
        assertEquals("${abc}", evaluateQueryForEscape("$${abc}", attributes));
        assertEquals("$xyz", evaluateQueryForEscape("$$${abc}", attributes));
        assertEquals("$${abc}", evaluateQueryForEscape("$$$${abc}", attributes));

        assertEquals( "Unescaped $$${5 because no closing brace", evaluateQueryForEscape("Unescaped $$${5 because no closing brace", attributes));
        assertEquals( "Unescaped $ because no closing brace", evaluateQueryForEscape("Unescaped $$${'5'} because no closing brace", attributes));

        assertEquals("I owe you $5", evaluateQueryForEscape("I owe you $5", attributes));
        assertEquals("You owe me $$5 too", evaluateQueryForEscape("You owe me $$5 too", attributes));
        assertEquals("Unescaped $$${5 because no closing brace", evaluateQueryForEscape("Unescaped $$${5 because no closing brace", attributes));
        assertEquals("xyz owes me $5", evaluateQueryForEscape("${abc} owes me $5", attributes));
        assertEquals("xyz owes me ${5", evaluateQueryForEscape("${abc} owes me ${5", attributes));
        assertEquals("xyz owes me ", evaluateQueryForEscape("${abc} owes me ${'5'}", attributes));
        assertEquals("xyz owes me $", evaluateQueryForEscape("${abc} owes me $$${'5'}", attributes));

        assertEquals("SNAP$$$$$$$$$", evaluateQueryForEscape("${literal('SNAP$$$$$$$$$')}", attributes));
        assertEquals("SNAP$$", evaluateQueryForEscape("${literal('SNAP$$')}", attributes));
        assertEquals("hello", evaluateQueryForEscape("${${abc}}", attributes));
        assertEquals("good-bye", evaluateQueryForEscape("${'$$${abc}'}", attributes));
        assertEquals("good-bye", evaluateQueryForEscape("${'$xyz'}", attributes));
    }

    @Test
    public void testWithBackSlashes() {
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("x", "C:\\test\\1.txt");
        attributes.put("y", "y\ny");

        final String query = "${x:substringAfterLast( '/' ):substringAfterLast( '\\\\' )}";
        verifyEquals(query, attributes, "1.txt");
        attributes.put("x", "C:/test/1.txt");
        verifyEquals(query, attributes, "1.txt");

        verifyEquals("${y:equals('y\\ny')}", attributes, Boolean.TRUE);
    }

    @Test
    public void testWithTicksOutside() {
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("attr", "My Value");
        assertEquals(1, Query.extractExpressionRanges("\"${attr}").size());
        assertEquals(1, Query.extractExpressionRanges("'${attr}").size());
        assertEquals(1, Query.extractExpressionRanges("'${attr}'").size());
        assertEquals(1, Query.extractExpressionRanges("${attr}").size());

        assertEquals("'My Value'", Query.evaluateExpressions("'${attr}'", attributes, null));
        assertEquals("'My Value", Query.evaluateExpressions("'${attr}", attributes, null));
    }

    @Test
    public void testDateToNumber() {
        final Query query = Query.compile("${dateTime:toDate('yyyy/MM/dd HH:mm:ss.SSS', 'America/New_York'):toNumber()}");
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("dateTime", "2013/11/18 10:22:27.678");

        final QueryResult<?> result = query.evaluate(new StandardEvaluationContext(attributes));
        assertEquals(ResultType.WHOLE_NUMBER, result.getResultType());
        assertEquals(1384788147678L, result.getValue());
    }

    @Test
    public void testInstantToNumber() {
        final Query query = Query.compile("${dateTime:toInstant('yyyy/MM/dd HH:mm:ss.SSS', 'America/New_York'):toNumber()}");
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("dateTime", "2013/11/18 10:22:27.678");

        final QueryResult<?> result = query.evaluate(new StandardEvaluationContext(attributes));
        assertEquals(ResultType.WHOLE_NUMBER, result.getResultType());
        assertEquals(1384788147678L, result.getValue());
    }

    @Test
    public void testInstantToNanos() {
        final String pattern = "yyyy/MM/dd HH:mm:ss.SSSSSSSSS";
        final String dateTime = "2022/03/18 10:22:27.678234567";
        final String zoneId = "America/New_York";

        final Query query = Query.compile(String.format("${dateTime:toInstant('%s', '%s'):toNanos()}", pattern, zoneId));
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("dateTime", dateTime);

        final Instant instant = DateTimeFormatter
                .ofPattern(pattern)
                .withZone(ZoneId.of(zoneId))
                .parse(dateTime, Instant::from);

        final QueryResult<?> result = query.evaluate(new StandardEvaluationContext(attributes));
        assertEquals(ResultType.NUMBER, result.getResultType());
        assertEquals(TimeUnit.SECONDS.toNanos(instant.getEpochSecond()) + instant.getNano(), result.getValue());
    }

    @Test
    public void testInstantToMicros() {
        final String pattern = "yyyy/MM/dd HH:mm:ss.SSSSSSSSS";
        final String dateTime = "2022/03/18 10:22:27.678234567";
        final String zoneId = "America/New_York";
        final Query query = Query.compile(String.format("${dateTime:toInstant('%s', '%s'):toMicros()}", pattern, zoneId));
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("dateTime", dateTime);

        final Instant instant = DateTimeFormatter
                .ofPattern(pattern)
                .withZone(ZoneId.of(zoneId))
                .parse(dateTime, Instant::from);

        final QueryResult<?> result = query.evaluate(new StandardEvaluationContext(attributes));
        assertEquals(ResultType.NUMBER, result.getResultType());
        assertEquals(ChronoUnit.MICROS.between(Instant.EPOCH, instant), result.getValue());
    }

    @Test
    public void testAddOneDayToDate() {
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("dateTime", "2013/11/18 10:22:27.678");

        verifyEquals("${dateTime:toDate('yyyy/MM/dd HH:mm:ss.SSS'):toNumber():plus(86400000):toDate():format('yyyy/MM/dd HH:mm:ss.SSS')}", attributes, "2013/11/19 10:22:27.678");
        verifyEquals("${dateTime:toDate('yyyy/MM/dd HH:mm:ss.SSS'):plus(86400000):format('yyyy/MM/dd HH:mm:ss.SSS')}", attributes, "2013/11/19 10:22:27.678");
    }

    @Test
    public void testAddOneDayToInstant() {
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("dateTime", "2013/11/18 10:22:27.678");

        verifyEquals("${dateTime:toInstant('yyyy/MM/dd HH:mm:ss.SSS', 'America/New_York'):toNumber():plus(86400000)" +
                ":toInstant('yyyy/MM/dd HH:mm:ss.SSS', 'America/New_York'):formatInstant('yyyy/MM/dd HH:mm:ss.SSS', 'America/New_York')}", attributes, "2013/11/19 10:22:27.678");
        verifyEquals("${dateTime:toInstant('yyyy/MM/dd HH:mm:ss.SSS', 'America/New_York'):plus(86400000)" +
                ":format('yyyy/MM/dd HH:mm:ss.SSS', 'America/New_York')}", attributes, "2013/11/19 10:22:27.678");
    }

    @Test
    @Disabled("Requires specific locale")
    public void implicitDateConversion() {
        final Date date = new Date();
        final Query query = Query.compile("${dateTime:format('yyyy/MM/dd HH:mm:ss.SSS')}");
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("dateTime", date.toString());

        // the date.toString() above will end up truncating the milliseconds. So remove millis from the Date before
        // formatting it
        final SimpleDateFormat sdf = new SimpleDateFormat("yyyy/MM/dd HH:mm:ss.SSS", Locale.US);
        final long millis = date.getTime() % 1000L;
        final Date roundedToNearestSecond = new Date(date.getTime() - millis);
        final String formatted = sdf.format(roundedToNearestSecond);

        final QueryResult<?> result = query.evaluate(new StandardEvaluationContext(attributes));
        assertEquals(ResultType.STRING, result.getResultType());
        assertEquals(formatted, result.getValue());
    }

    @Test
    @Disabled("Requires specific locale")
    public void implicitInstantConversion() {
        final Instant instant = Instant.now();
        final Query query = Query.compile("${dateTime:formatInstant('yyyy/MM/dd HH:mm:ss.SSS', 'America/New_York')}");
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("dateTime", instant.toString());

        final DateTimeFormatter dtf = DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm:ss.SSS", Locale.US)
                .withZone(ZoneId.of("America/New_York"));
        final String formatted = dtf.format(instant);

        final QueryResult<?> result = query.evaluate(new StandardEvaluationContext(attributes));
        assertEquals(ResultType.STRING, result.getResultType());
        assertEquals(formatted, result.getValue());
    }

    @Test
    public void testEmbeddedExpressionsAndQuotes() {
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("x", "abc");
        attributes.put("a", "abc");

        verifyEquals("${x:equals(${a})}", attributes, true);

        Query.validateExpression("${x:equals('${a}')}", false);
        assertEquals("true", Query.evaluateExpressions("${x:equals('${a}')}", attributes, null));

        Query.validateExpression("${x:equals(\"${a}\")}", false);
        assertEquals("true", Query.evaluateExpressions("${x:equals(\"${a}\")}", attributes, null));
    }

    @Test
    public void testParameterReference() {
        final Map<String, String> attributes = Collections.emptyMap();
        final Map<String, String> stateValues = Collections.emptyMap();
        final Map<String, String> parameters = new HashMap<>();
        parameters.put("test", "unit");

        verifyEquals("${#{test}}", attributes, stateValues, parameters,"unit");
        verifyEquals("${#{test}:append(' - '):append(#{test})}", attributes, stateValues, parameters,"unit - unit");
    }

    @Test
    public void testParameterReferenceWithSpace() {
        final Map<String, String> attributes = Collections.emptyMap();
        final Map<String, String> stateValues = Collections.emptyMap();
        final Map<String, String> parameters = new HashMap<>();
        parameters.put("test param", "unit");

        verifyEquals("${#{'test param'}}", attributes, stateValues, parameters,"unit");
        verifyEquals("${#{'test param'}:append(' - '):append(#{'test param'})}", attributes, stateValues, parameters,"unit - unit");

        verifyEquals("${#{\"test param\"}}", attributes, stateValues, parameters,"unit");
    }

    @Test
    public void testJsonPath() throws IOException {
        Map<String,String> attributes = verifyJsonPathExpressions(
            ADDRESS_BOOK_JSON_PATH_EMPTY,
            "", "${json:jsonPathDelete('$.missingpath')}", "");
        verifyEquals("${json:jsonPath('$.missingpath')}", attributes, "");

        assertThrows(AttributeExpressionLanguageException.class,
                () -> verifyEquals("${json:jsonPath('$..')}", attributes, ""));
        assertThrows(AttributeExpressionLanguageException.class,
                () -> verifyEquals("${missing:jsonPath('$.firstName')}", attributes, ""));

        attributes.put("invalid", "[}");
        assertThrows(AttributeExpressionLanguageException.class,
                () -> verifyEquals("${invlaid:jsonPath('$.firstName')}", attributes, "John"));
    }

    private void verifyAddressBookAttributes(String originalAddressBook, Map<String,String> attributes, String updatedAttribute, Object updatedValue) {

        if (StringUtils.isBlank(attributes.get("json"))) {
            throw new IllegalArgumentException("original Json attributes is empty");
        }

        Map<String, String> originalAttributes = new HashMap<>();
        originalAttributes.put("json", originalAddressBook);

        phoneBookAttributes.stream()
                .filter(currentAttribute -> !currentAttribute.equals(updatedAttribute))
                .forEach(currentAttribute -> {
                            String expected = Query.evaluateExpressions(currentAttribute, originalAttributes, null, null, ParameterLookup.EMPTY);
                            verifyEquals(currentAttribute, attributes, expected);
                        }
                );
        if (! ADDRESS_BOOK_JSON_PATH_EMPTY.equals(updatedAttribute) ) {
            verifyEquals(updatedAttribute, attributes, updatedValue);
        }
    }

    private Map<String,String> verifyJsonPathExpressions(String targetAttribute, Object originalValue, String updateExpression, Object updatedValue) throws IOException {
        final Map<String, String> attributes = new HashMap<>();
        String addressBook = getResourceAsString("/json/address-book.json");
        attributes.put("json", addressBook);

        if ( ! ADDRESS_BOOK_JSON_PATH_EMPTY.equals(targetAttribute) ) {
            verifyEquals(targetAttribute, attributes, originalValue);
        }

        String addressBookAfterUpdate = Query.evaluateExpressions(updateExpression, attributes, ParameterLookup.EMPTY);
        attributes.clear();
        attributes.put("json", addressBookAfterUpdate);

        verifyAddressBookAttributes(addressBook, attributes, targetAttribute, updatedValue);

        return attributes;
    }

    @Test
    public void testJsonPathDeleteFirstNameAttribute() throws IOException {
        verifyJsonPathExpressions(
            ADDRESS_BOOK_JSON_PATH_FIRST_NAME,
            "John",
            "${json:jsonPathDelete('$.firstName')}",
            ""
        );
    }

    @Test
    public void testJsonPathDeleteMissingPath() throws IOException {
       verifyJsonPathExpressions(
            ADDRESS_BOOK_JSON_PATH_EMPTY,
            "",
            "${json:jsonPathDelete('$.missing-path')}",
            "");
    }

    @Test
    public void testJsonPathDeleteHomePhoneNumber() throws IOException {
        verifyJsonPathExpressions(
            ADDRESS_BOOK_JSON_PATH_PHONE_NUMBERS_TYPE_HOME_NUMBER,
            "212 555-1234",
            "${json:jsonPathDelete(\"$.phoneNumbers[?(@.type=='home')]\")}",
            "[]");
    }

    @Test
    public void testJsonPathSetFirstNameAttribute() throws IOException {
        verifyJsonPathExpressions(
            ADDRESS_BOOK_JSON_PATH_FIRST_NAME,
            "John",
            "${json:jsonPathSet('$.firstName', 'James')}",
            "James");
    }

    @Test
    public void testJsonPathSetAgeWholeNumberAttribute() throws IOException {
        verifyJsonPathExpressions(
            ADDRESS_BOOK_JSON_PATH_AGE,
            "25",
            "${json:jsonPathSet('$.age', '35')}",
            "35");
    }

    @Test
    public void testJsonPathSetVoterBooleanAttribute() throws IOException {
        verifyJsonPathExpressions(
            ADDRESS_BOOK_JSON_PATH_VOTER,
            "true",
            "${json:jsonPathSet('$.voter', false)}",
            "false");
    }

    @Test
    public void testJsonPathSetHeightNumberAttribute() throws IOException {
        verifyJsonPathExpressions(
            ADDRESS_BOOK_JSON_PATH_HEIGHT,
            "6.1",
            "${json:jsonPathSet('$.height', 5.9)}",
            "5.9");
    }

    @Test
    public void testJsonPathSetMissingPathAttribute() throws IOException {
        verifyJsonPathExpressions(
            ADDRESS_BOOK_JSON_PATH_EMPTY,
            "",
            "${json:jsonPathSet('$.missing-path', 5.9)}",
            "");
    }

    @Test
    public void testJsonPathAddNicknameJimmy() throws IOException {
        Map<String,String> attributes = verifyJsonPathExpressions(
                ADDRESS_BOOK_JSON_PATH_EMPTY,
                "",
                "${json:jsonPathAdd('$.nicknames', 'Jimmy')}",
                "");
        verifyEquals("${json:jsonPath('$.nicknames')}", attributes, "Jimmy");
    }

    @Test
    public void testJsonPathAddNicknameJimmyAtNonexistantPath() throws IOException {
        Map<String,String> attributes = verifyJsonPathExpressions(
                ADDRESS_BOOK_JSON_PATH_EMPTY,
                "",
                "${json:jsonPathAdd('$.missing-path', 'Jimmy')}",
                "");
       verifyEquals("${json:jsonPath('$.missing-path')}", attributes, "");
    }

    @Test
    public void testJsonPathAddNicknameJimmyAtNonArray() {
        assertThrows(IllegalArgumentException.class, () -> verifyJsonPathExpressions(
                ADDRESS_BOOK_JSON_PATH_EMPTY,
                "",
                "${json:jsonPathAdd('$.firstName', 'Jimmy')}",
                ""));
    }

    @Test
    public void testJsonPathPutRootLevelMiddlenameTuron() throws IOException {
        Map<String,String> attributes = verifyJsonPathExpressions(
                ADDRESS_BOOK_JSON_PATH_EMPTY,
                "",
                "${json:jsonPathPut('$','middlename','Turon')}",
                "");
        verifyEquals("${json:jsonPath('$.middlename')}", attributes, "Turon");
    }

    @Test
    public void testJsonPathPutCountryToMap() throws IOException {
        Map<String,String> attributes = verifyJsonPathExpressions(
                ADDRESS_BOOK_JSON_PATH_EMPTY,
                "",
                "${json:jsonPathPut('$.address','country','US')}",
                "");
        verifyEquals("${json:jsonPath('$.address.country')}", attributes, "US");
    }

    @Test
    public void testJsonPathPutElementToArray() throws IOException {
        Map<String,String> attributes = verifyJsonPathExpressions(
                ADDRESS_BOOK_JSON_PATH_EMPTY,
                "",
                "${json:jsonPathPut('$.phoneNumbers[1]', 'backup', '212-555-1212')}",
                "");
        verifyEquals("${json:jsonPath('$.phoneNumbers[1].backup')}", attributes, "212-555-1212");
    }

    @Test
    public void testJsonPathPutOverwriteFirstNameToJimmy() throws IOException {
        verifyJsonPathExpressions(
                ADDRESS_BOOK_JSON_PATH_FIRST_NAME,
                "John",
                "${json:jsonPathPut('$','firstName','Jimmy')}",
                "Jimmy");
    }

    @Test
    public void testEmbeddedExpressionsAndQuotesWithProperties() {
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("x", "abc");
        attributes.put("a", "abc");

        verifyEquals("${x:equals(${a})}", attributes, true);

        Query.validateExpression("${x:equals('${a}')}", false);
        assertEquals("true", Query.evaluateExpressions("${x:equals('${a}')}", attributes, null));

        Query.validateExpression("${x:equals(\"${a}\")}", false);
        assertEquals("true", Query.evaluateExpressions("${x:equals(\"${a}\")}", attributes, null));
    }

    @Test
    public void testJoin() {
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("a.a", "a");
        attributes.put("a.b", "b");
        attributes.put("a.c", "c");
        verifyEquals("${allAttributes( 'a.a', 'a.b', 'a.c' ):join(', ')}", attributes, "a, b, c");
        verifyEquals("${x:join(', ')}", attributes, "");
        verifyEquals("${a.a:join(', ')}", attributes, "a");
        verifyEquals("${allAttributes( 'x', 'y' ):join(',')}", attributes, ",");
    }

    @Test
    public void testCannotCombineWithNonReducingFunction() {
        assertThrows(AttributeExpressionLanguageException.class, () -> Query.compile("${allAttributes( 'a.1' ):plus(1)}"));
    }

    @Test
    public void testIsEmpty() {
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("a", "a");
        attributes.put("b", "");
        attributes.put("c", "        \n");

        verifyEquals("${a:isEmpty()}", attributes, false);
        verifyEquals("${b:isEmpty()}", attributes, true);
        verifyEquals("${c:isEmpty()}", attributes, true);
        verifyEquals("${d:isEmpty()}", attributes, true);
    }

    @Test
    public void testReplaceEmpty() {
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("a", "a");
        attributes.put("b", "");
        attributes.put("c", "        \n");

        verifyEquals("${a:replaceEmpty('c')}", attributes, "a");
        verifyEquals("${b:replaceEmpty('c')}", attributes, "c");
        verifyEquals("${c:replaceEmpty('c')}", attributes, "c");
        verifyEquals("${d:replaceEmpty('c')}", attributes, "c");
    }

    @Test
    public void testCount() {
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("a", "a");
        attributes.put("b", "abc");
        attributes.put("c", "        \n");
        attributes.put("n1", "111");
        attributes.put("n2", "222");
        attributes.put("n3", "333333");

        verifyEquals("${allMatchingAttributes( '.*' ):count()}", attributes, 6L);
        verifyEquals("${allMatchingAttributes( '.*' ):length():gt(2):count()}", attributes, 5L);
        verifyEquals("${allMatchingAttributes( 'n.*' ):plus(1):count()}", attributes, 3L);
    }

    @Test
    public void testCurlyBracesInQuotes() {
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("attr", "My Valuee");

        assertEquals("Val", evaluateQueryForEscape("${attr:replaceAll('My (Val)ue{1,2}', '$1')}", attributes));
        assertEquals("Val", evaluateQueryForEscape("${attr:replaceAll(\"My (Val)ue{1,2}\", '$1')}", attributes));
    }

    @SuppressWarnings("unchecked")
    private String evaluateQueryForEscape(final String queryString, final Map<String, String> attributes) {
        final FlowFile mockFlowFile = mock(FlowFile.class);
        when(mockFlowFile.getAttributes()).thenReturn(attributes);
        when(mockFlowFile.getId()).thenReturn(1L);
        when(mockFlowFile.getEntryDate()).thenReturn(System.currentTimeMillis());
        when(mockFlowFile.getSize()).thenReturn(1L);
        when(mockFlowFile.getLineageStartDate()).thenReturn(System.currentTimeMillis());

        final ValueLookup lookup = new ValueLookup(VariableRegistry.EMPTY_REGISTRY, mockFlowFile);
        return Query.evaluateExpressions(queryString, lookup, ParameterLookup.EMPTY);
    }

    @Test
    public void testGetAttributeValue() {
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("attr", "My Value");
        verifyEquals("${attr}", attributes, "My Value");
    }

    @Test
    public void testGetAttributeValueEmbedded() {
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("attr", "XX ");
        attributes.put("XX", "My Value");
        verifyEquals("${${attr:trim()}}", attributes, "My Value");
    }

    @Test
    public void testSimpleSubstring() {
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("attr", "My Value");
        verifyEquals("${attr:substring(2, 5)}", attributes, " Va");
    }

    @Test
    public void testCallToFunctionWithSubjectResultOfAnotherFunctionCall() {
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("attr", "   My Value   ");
        verifyEquals("${attr:trim():substring(2, 5)}", attributes, " Va");
    }

    @Test
    public void testProblematic1() {
        // There was a bug that prevented this expression from compiling. This test just verifies that it now compiles.
        final String queryString = "${xx:append( \"120101\" ):toDate( 'yyMMddHHmmss' ):format( \"yy-MM-dd’T’HH:mm:ss\") }";
        Query.compile(queryString);
    }

    @Test
    public void testEquals() {
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("attr", " XX    ");
        verifyEquals("${attr:trim():equals('XX')}", attributes, true);
    }

    @Test
    public void testDeeplyEmbedded() {
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("x", "false");
        attributes.put("abc", "a");
        attributes.put("a", "a");

        verifyEquals("${x:or( ${${abc}:length():equals(1)} )}", attributes, true);
    }

    @Test
    public void testExtractExpressionRanges() {
        assertEquals(29, Query.extractExpressionRanges("${hello:equals( $${goodbye} )}").get(0).getEnd());

        List<Range> ranges = Query.extractExpressionRanges("hello");
        assertTrue(ranges.isEmpty());

        ranges = Query.extractExpressionRanges("${hello");
        assertTrue(ranges.isEmpty());

        ranges = Query.extractExpressionRanges("hello}");
        assertTrue(ranges.isEmpty());

        ranges = Query.extractExpressionRanges("$${hello");
        assertTrue(ranges.isEmpty());

        ranges = Query.extractExpressionRanges("$he{ll}o");
        assertTrue(ranges.isEmpty());

        ranges = Query.extractExpressionRanges("${hello}");
        assertEquals(1, ranges.size());
        Range range = ranges.get(0);
        assertEquals(0, range.getStart());
        assertEquals(7, range.getEnd());

        ranges = Query.extractExpressionRanges("${hello:equals( ${goodbye} )}");
        assertEquals(1, ranges.size());
        range = ranges.get(0);
        assertEquals(0, range.getStart());
        assertEquals(28, range.getEnd());

        ranges = Query.extractExpressionRanges("${hello:equals( $${goodbye} )}");
        assertEquals(1, ranges.size());
        range = ranges.get(0);
        assertEquals(0, range.getStart());
        assertEquals(29, range.getEnd());

        ranges = Query.extractExpressionRanges("${hello:equals( $${goodbye} )} or just hi, ${bob:or(${jerry})}");
        assertEquals(2, ranges.size());
        range = ranges.get(0);
        assertEquals(0, range.getStart());
        assertEquals(29, range.getEnd());

        range = ranges.get(1);
        assertEquals(43, range.getStart());
        assertEquals(61, range.getEnd());

        ranges = Query.extractExpressionRanges("${hello:equals( ${goodbye} )} or just hi, ${bob}, are you ${bob.age:toNumber()} yet? $$$${bob}");
        assertEquals(3, ranges.size());
        range = ranges.get(0);
        assertEquals(0, range.getStart());
        assertEquals(28, range.getEnd());

        range = ranges.get(1);
        assertEquals(42, range.getStart());
        assertEquals(47, range.getEnd());

        range = ranges.get(2);
        assertEquals(58, range.getStart());
        assertEquals(78, range.getEnd());

        ranges = Query.extractExpressionRanges("${x:matches( '.{4}' )}");
        assertEquals(1, ranges.size());
        range = ranges.get(0);
        assertEquals(0, range.getStart());
        assertEquals(21, range.getEnd());
    }

    @Test
    public void testExtractExpressionTypes() {
        final List<ResultType> types = Query.extractResultTypes("${hello:equals( ${goodbye} )} or just hi, ${bob}, are you ${bob.age:toNumber()} yet? $$$${bob}");
        assertEquals(3, types.size());
        assertEquals(ResultType.BOOLEAN, types.get(0));
        assertEquals(ResultType.STRING, types.get(1));
        assertEquals(ResultType.WHOLE_NUMBER, types.get(2));
    }

    @Test
    public void testEqualsEmbedded() {
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("x", "hello");
        attributes.put("y", "good-bye");

        verifyEquals("${x:equals( ${y} )}", attributes, false);

        attributes.put("y", "hello");
        verifyEquals("${x:equals( ${y} )}", attributes, true);

        attributes.put("x", "4");
        attributes.put("y", "3");
        attributes.put("z", "1");
        attributes.put("h", "100");
        verifyEquals("${x:toNumber():lt( ${y:toNumber():plus( ${h:toNumber()} )} )}", attributes, true);
        verifyEquals("${h:toNumber():ge( ${y:toNumber():plus( ${z:toNumber()} )} )}", attributes, true);
        verifyEquals("${x:toNumber():equals( ${y:toNumber():plus( ${z:toNumber()} )} )}", attributes, true);

        attributes.put("x", "88");
        verifyEquals("${x:toNumber():gt( ${y:toNumber():plus( ${z:toNumber()} )} )}", attributes, true);

        attributes.put("y", "88");
        assertEquals("true", Query.evaluateExpressions("${x:equals( '${y}' )}", attributes, null));
    }

    @Test
    public void testComplicatedEmbeddedExpressions() {
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("fox", "quick, brown");
        attributes.put("dog", "lazy");

        verifyEquals("${fox:substring( ${ 'dog' :substring(2):length()}, 5 ):equals( 'ick' )}", attributes, true);
        verifyEquals("${fox:substring( ${ 'dog' :substring(2):length()}, 5 ):equals( 'ick' )}", attributes, true);
    }

    @Test
    public void testQuotingQuotes() {
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("xx", "say 'hi'");

        String query = "${xx:replaceAll( \"'.*'\", '\\\"hello\\\"' )}";
        verifyEquals(query, attributes, "say \"hello\"");

        query = "${xx:replace( \"'\", '\"')}";
        verifyEquals(query, attributes, "say \"hi\"");

        query = "${xx:replace( '\\'', '\"')}";
        verifyEquals(query, attributes, "say \"hi\"");
    }

    @Test
    public void testDoubleQuotesWithinSingleQuotes() {
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("xx", "say 'hi'");

        final String query = "${xx:replace( \"'hi'\", '\\\"hello\\\"' )}";
        verifyEquals(query, attributes, "say \"hello\"");
    }

    @Test
    public void testEscapeQuotes() {
        final long timestamp = 1403620278642L;
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("date", String.valueOf(timestamp));

        final String format = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'";

        final String query = "startDateTime=\"${date:toNumber():toDate():format(\"" + format + "\")}\"";
        final String result = Query.evaluateExpressions(query, attributes, null);

        final String expectedTime = new SimpleDateFormat(format, Locale.US).format(timestamp);
        assertEquals("startDateTime=\"" + expectedTime + "\"", result);

        final List<Range> ranges = Query.extractExpressionRanges(query);
        assertEquals(1, ranges.size());
    }

    @Test
    public void testInstantEscapeQuotes() {
        final long timestamp = 1403620278642L;
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("date", String.valueOf(timestamp));

        final String format = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'";

        final String query = "startDateTime=\"${date:toNumber():toInstant('yyyy/MM/dd HH:mm:ss.SSS', 'America/New_York'):formatInstant(\"" + format + "\", 'America/New_York')}\"";
        final String result = Query.evaluateExpressions(query, attributes, null);

        final String expectedTime = DateTimeFormatter.ofPattern(format, Locale.US)
                .withZone(ZoneId.of("America/New_York"))
                .format(Instant.ofEpochMilli(timestamp));
        assertEquals("startDateTime=\"" + expectedTime + "\"", result);

        final List<Range> ranges = Query.extractExpressionRanges(query);
        assertEquals(1, ranges.size());
    }

    @Test
    public void testDateConversion() {
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("date", "1403620278642");

        verifyEquals("${date:format('yyyy')}", attributes, "2014");
        verifyEquals("${date:toDate():format('yyyy')}", attributes, "2014");
        verifyEquals("${date:toNumber():format('yyyy')}", attributes, "2014");
        verifyEquals("${date:toNumber():toDate():format('yyyy')}", attributes, "2014");
        verifyEquals("${date:toDate():toNumber():format('yyyy')}", attributes, "2014");
        verifyEquals("${date:toDate():toNumber():toDate():toNumber():toDate():toNumber():format('yyyy')}", attributes, "2014");
    }

    @Test
    public void testInstantConversion() {
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("instant", "1403620278642");

        verifyEquals("${instant:formatInstant('yyyy', 'America/New_York')}", attributes, "2014");
        verifyEquals("${instant:toInstant():formatInstant('yyyy', 'America/New_York')}", attributes, "2014");
        verifyEquals("${instant:toNumber():formatInstant('yyyy', 'America/New_York')}", attributes, "2014");
        verifyEquals("${instant:toNumber():toInstant():formatInstant('yyyy', 'America/New_York')}", attributes, "2014");
        verifyEquals("${instant:toInstant():toNumber():formatInstant('yyyy', 'America/New_York')}", attributes, "2014");
        verifyEquals("${instant:toInstant():toNumber():toInstant():toNumber():toInstant():toNumber():formatInstant('yyyy', 'America/New_York')}", attributes, "2014");
    }

    @Test
    public void testSingleLetterAttribute() {
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("A", "0123456789");

        verifyEquals("${A}", attributes, "0123456789");
        verifyEquals("${'A'}", attributes, "0123456789");
    }

    @Test
    public void testImplicitConversions() {
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("A", "0123456789");
        attributes.put("b", "true");
        attributes.put("c", "false");
        attributes.put("d", "Quick Brown Fox");
        attributes.put("F", "-48");
        attributes.put("n", "2014/04/04 00:00:00");

        final Calendar cal = Calendar.getInstance();
        cal.set(Calendar.YEAR, 2014);
        cal.set(Calendar.MONTH, 3);
        cal.set(Calendar.DAY_OF_MONTH, 4);
        cal.set(Calendar.HOUR, 0);
        cal.set(Calendar.MINUTE, 0);
        cal.set(Calendar.SECOND, 45);

        final String dateString = cal.getTime().toString();
        attributes.put("z", dateString);

        verifyEquals("${A:plus(4)}", attributes, 123456793L);
        verifyEquals("${A:plus( ${F} )}", attributes, 123456741L);

        verifyEquals("${F:lt( ${A} )}", attributes, true);
        verifyEquals("${A:substring(2,3):plus(21):substring(1,2):plus(0)}", attributes, 3L);
        verifyEquals("${n:format( 'yyyy' )}", attributes, "2014");
        verifyEquals("${z:format( 'yyyy' )}", attributes, "2014");

        attributes.put("n", "2014/04/04 00:00:00.045");
        verifyEquals("${n:format( 'yyyy' ):append(','):append( ${n:format( 'SSS' )} )}", attributes, "2014,045");
    }

    @Test
    public void testNewLinesAndTabsInQuery() {
        final String query = "${ abc:equals('abc'):or( \n\t${xx:isNull()}\n) }";
        assertEquals(ResultType.BOOLEAN, Query.getResultType(query));
        Query.validateExpression(query, false);
        assertEquals("true", Query.evaluateExpressions(query, Collections.emptyMap(), ParameterLookup.EMPTY));
    }

    @Test
    public void testAttributeReferencesWithWhiteSpace() {
        final Map<String, String> attrs = new HashMap<>();
        attrs.put("a b c,d", "abc");

        final String query = "${ 'a b c,d':equals('abc') }";
        verifyEquals(query, attrs, true);
    }

    @Test
    public void testComments() {
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("abc", "xyz");
        final String expression
                = "# hello, world\n"
                + "${# ref attr\n"
                + "\t"
                + "abc"
                + "\t"
                + "#end ref attr\n"
                + "}";

        Query query = Query.compile(expression);
        QueryResult<?> result = query.evaluate(new StandardEvaluationContext(attributes));
        assertEquals(ResultType.STRING, result.getResultType());
        assertEquals("xyz", result.getValue());

        query = Query.compile("${abc:append('# hello') #good-bye \n}");
        result = query.evaluate(new StandardEvaluationContext(attributes));
        assertEquals(ResultType.STRING, result.getResultType());
        assertEquals("xyz# hello", result.getValue());
    }

    @Test
    public void testAppendPrepend() {
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("attr", "XX");
        attributes.put("YXXX", "bingo");

        verifyEquals("${${attr:append('X'):prepend('Y')}}", attributes, "bingo");
    }

    @Test
    public void testIsNull() {
        final Map<String, String> attributes = new HashMap<>();
        verifyEquals("${attr:isNull()}", attributes, true);
    }

    @Test
    public void testNotNull() {
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("attr", "");

        verifyEquals("${attr:notNull()}", attributes, true);
    }

    @Test
    public void testIsNullOrLengthEquals0() {
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("abc", "");
        attributes.put("xyz", "xyz");
        attributes.put("xx", "  ");

        verifyEquals("${abc:isNull():or( ${abc:length():equals(0)} )}", attributes, true);
        verifyEquals("${xyz:isNull():or( ${xyz:length():equals(0)} )}", attributes, false);
        verifyEquals("${none:isNull():or( ${none:length():equals(0)} )}", attributes, true);
        verifyEquals("${xx:isNull():or( ${xx:trim():length():equals(0)} )}", attributes, true);
    }

    @Test
    public void testReplaceNull() {
        final Map<String, String> attributes = new HashMap<>();
        verifyEquals("${attr:replaceNull('hello')}", attributes, "hello");
    }

    @Test
    public void testReplace() {
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("attr", "hello");
        verifyEquals("${attr:replace('hell', 'yell')}", attributes, "yello");
    }

    @Test
    public void testReplaceAll() {
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("attr", "hello");
        attributes.put("xyz", "00-00TEST.2014_01_01_000000_value");

        verifyEquals("${xyz:replaceAll(\"^([^.]+)\\.([0-9]{4})_([0-9]{2})_([0-9]{2}).*$\", \"$3\")}", attributes, "01");
        verifyEquals("${attr:replaceAll('l+', 'r')}", attributes, "hero");

        attributes.clear();
        attributes.put("filename1", "abc.gz");
        attributes.put("filename2", "abc.g");
        attributes.put("filename3", "abc.gz.gz");
        attributes.put("filename4", "abc.gz.g");
        attributes.put("abc", "hello world");

        verifyEquals("${filename3:replaceAll('\\\\\\.gz$', '')}", attributes, "abc.gz.gz");
        verifyEquals("${filename3:replaceAll('\\\\\\\\.gz$', '')}", attributes, "abc.gz.gz");
        verifyEquals("${filename1:replaceAll('\\.gz$', '')}", attributes, "abc");
        verifyEquals("${filename2:replaceAll('\\.gz$', '')}", attributes, "abc.g");
        verifyEquals("${filename4:replaceAll('\\\\.gz$', '')}", attributes, "abc.gz.g");

        verifyEquals("${abc:replaceAll( 'lo wor(ld)', '$0')}", attributes, "hello world");
        verifyEquals("${abc:replaceAll( 'he(llo) world', '$1')}", attributes, "llo");
        verifyEquals("${abc:replaceAll( 'xx', '$0')}", attributes, "hello world");
        verifyEquals("${abc:replaceAll( '(xx)', '$1')}", attributes, "hello world");
        verifyEquals("${abc:replaceAll( 'lo wor(ld)', '$1')}", attributes, "helld");

    }

    @Test
    public void testReplaceAllWithOddNumberOfBackslashPairs() {
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("filename", "C:\\temp\\.txt");

        verifyEquals("${filename:replace('\\\\', '/')}", attributes, "C:/temp/.txt");
        verifyEquals("${filename:replaceAll('\\\\\\\\', '/')}", attributes, "C:/temp/.txt");
        verifyEquals("${filename:replaceAll('\\\\\\.txt$', '')}", attributes, "C:\\temp");
    }

    @Test
    public void testReplaceAllWithMatchingGroup() {
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("attr", "hello");

        verifyEquals("${attr:replaceAll('.*?(l+).*', '$1')}", attributes, "ll");
    }

    @Test
    public void testReplaceShouldReplaceAllLiteralMatches() {
        int n = 3;
        final String originalValue = "Hello World";
        final Map<String, String> attributes = new LinkedHashMap<>();
        attributes.put("single", originalValue);
        attributes.put("repeating", StringUtils.repeat(originalValue, " ", n));
        final String replacementValue = "Goodbye Planet";
        final String expectedRepeatingResult = StringUtils.repeat(replacementValue, " ", n);
        final String replaceSingleExpression = "${single:replace('" + originalValue + "', '" + replacementValue + "')}";
        final String replaceRepeatingExpression = "${repeating:replace('" + originalValue + "', '" + replacementValue + "')}";
        Query replaceSingleQuery = Query.compile(replaceSingleExpression);
        Query replaceRepeatingQuery = Query.compile(replaceRepeatingExpression);

        QueryResult<?> replaceSingleResult = replaceSingleQuery.evaluate(new StandardEvaluationContext(attributes));
        QueryResult<?> replaceRepeatingResult = replaceRepeatingQuery.evaluate(new StandardEvaluationContext(attributes));

        assertEquals(replacementValue, replaceSingleResult.getValue());
        assertEquals(AttributeExpression.ResultType.STRING, replaceSingleResult.getResultType());
        assertEquals(expectedRepeatingResult, replaceRepeatingResult.getValue());
        assertEquals(AttributeExpression.ResultType.STRING, replaceRepeatingResult.getResultType());
    }

    @Test
    public void testReplaceFirstShouldOnlyReplaceFirstRegexMatch() {
        int n = 3;
        final String originalValue = "Hello World";
        final Map<String, String> attributes = new LinkedHashMap<>();
        attributes.put("single", originalValue);
        attributes.put("repeating", StringUtils.repeat(originalValue, " ", n));

        final String replacementValue = "Goodbye Planet";
        final String expectedRepeatingResult = replacementValue + " " + StringUtils.repeat(originalValue, " ", n -1);
        final String replaceOnlyFirstPattern = "\\w+\\s\\w+\\b??";
        final String replaceSingleExpression = "${single:replaceFirst('" + replaceOnlyFirstPattern +"', '" + replacementValue + "')}";
        final String replaceRepeatingExpression = "${repeating:replaceFirst('" + replaceOnlyFirstPattern + "', '" + replacementValue + "')}";
        Query replaceSingleQuery = Query.compile(replaceSingleExpression);
        Query replaceRepeatingQuery = Query.compile(replaceRepeatingExpression);

        QueryResult<?> replaceSingleResult = replaceSingleQuery.evaluate(new StandardEvaluationContext(attributes));
        QueryResult<?> replaceRepeatingResult = replaceRepeatingQuery.evaluate(new StandardEvaluationContext(attributes));

        assertEquals(replacementValue, replaceSingleResult.getValue());
        assertEquals(AttributeExpression.ResultType.STRING, replaceSingleResult.getResultType());
        assertEquals(expectedRepeatingResult, replaceRepeatingResult.getValue());
        assertEquals(AttributeExpression.ResultType.STRING, replaceRepeatingResult.getResultType());
    }

    @Test
    public void testReplaceFirstShouldOnlyReplaceFirstLiteralMatch() {
        int n = 3;
        final String originalValue = "Hello World";
        final Map<String, String> attributes = new LinkedHashMap<>();
        attributes.put("single", originalValue);
        attributes.put("repeating", StringUtils.repeat(originalValue, " ", n));
        final String replacementValue = "Goodbye Planet";
        final String expectedRepeatingResult = replacementValue + " " + StringUtils.repeat(originalValue, " ", n -1);
        final String replaceSingleExpression = "${single:replaceFirst('" + originalValue + "', '" + replacementValue + "')}";
        final String replaceRepeatingExpression = "${repeating:replaceFirst('" + originalValue + "', '" + replacementValue + "')}";
        Query replaceSingleQuery = Query.compile(replaceSingleExpression);
        Query replaceRepeatingQuery = Query.compile(replaceRepeatingExpression);

        QueryResult<?> replaceSingleResult = replaceSingleQuery.evaluate(new StandardEvaluationContext(attributes));
        QueryResult<?> replaceRepeatingResult = replaceRepeatingQuery.evaluate(new StandardEvaluationContext(attributes));

        assertEquals(replacementValue, replaceSingleResult.getValue());
        assertEquals(AttributeExpression.ResultType.STRING, replaceSingleResult.getResultType());
        assertEquals(expectedRepeatingResult, replaceRepeatingResult.getValue());
        assertEquals(AttributeExpression.ResultType.STRING, replaceRepeatingResult.getResultType());
    }

    @Test
    public void testShouldDemonstrateDifferenceBetweenStringReplaceAndStringReplaceFirst() {
        int n = 3;
        final String originalValue = "Hello World";
        final Map<String, String> attributes = new LinkedHashMap<>();
        attributes.put("single", originalValue);
        attributes.put("repeating", StringUtils.repeat(originalValue, " ", n));
        final String replacementValue = "Goodbye Planet";
        final String expectedRepeatingResult = replacementValue + " " + StringUtils.repeat(originalValue, " ", n -1);
        final String replaceOnlyFirstPattern = "\\w+\\s\\w+\\b??";

        // Execute on both single and repeating with String#replace()
        String replaceSingleResult = attributes.get("single").replace(replaceOnlyFirstPattern, replacementValue);
        String replaceRepeatingResult = attributes.get("repeating").replace(replaceOnlyFirstPattern, replacementValue);

        // Execute on both single and repeating with String#replaceFirst()
        String replaceFirstSingleResult = attributes.get("single").replaceFirst(replaceOnlyFirstPattern, replacementValue);
        String replaceFirstRepeatingResult = attributes.get("repeating").replaceFirst(replaceOnlyFirstPattern, replacementValue);

        assertNotEquals(replacementValue, replaceSingleResult);
        assertNotEquals(expectedRepeatingResult, replaceRepeatingResult);
        assertEquals(replacementValue, replaceFirstSingleResult);
        assertEquals(expectedRepeatingResult, replaceFirstRepeatingResult);
    }

    @Test
    public void testMathWholeNumberOperations() {
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("one", "1");
        attributes.put("two", "2");
        attributes.put("three", "3");
        attributes.put("four", "4");
        attributes.put("five", "5");
        attributes.put("hundred", "100");

        verifyEquals("${hundred:toNumber():multiply(${two}):divide(${three}):plus(${one}):mod(${five})}", attributes, 2L);
    }

    @Test
    public void testMathDecimalOperations() {
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("first", "1.5");
        attributes.put("second", "12.3");
        attributes.put("third", "3");
        attributes.put("fourth", "4.201");
        attributes.put("fifth", "5.1");
        attributes.put("hundred", "100");

        // The expected resulted is calculated instead of a set number due to the inaccuracy of double arithmetic
        verifyEquals("${hundred:toNumber():multiply(${second}):divide(${third}):plus(${first}):mod(${fifth})}", attributes, (((100 * 12.3) / 3) + 1.5) %5.1);
    }

    @Test
    public void testMathResultInterpretation() {
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("ten", "10.1");
        attributes.put("two", "2.2");

        // The expected resulted is calculated instead of a set number due to the inaccuracy of double arithmetic
        verifyEquals("${ten:divide(${two:plus(3)}):toNumber()}", attributes, (Double.valueOf(10.1 / (2.2 + 3)).longValue()));

        // The expected resulted is calculated instead of a set number due to the inaccuracy of double arithmetic
        verifyEquals("${ten:divide(${two:plus(3)}):toDecimal()}", attributes, (10.1 / (2.2 + 3)));

        // The expected resulted is calculated instead of a set number due to the inaccuracy of double arithmetic
        verifyEquals("${ten:divide(${two:plus(3.1)}):toDecimal()}", attributes, (10.1 / (2.2 + 3.1)));

        verifyEquals("${ten:divide(${two:plus(3)}):toDate():format(\"SSS\")}", attributes, "001");
    }

    @Test
    public void testMathFunction() {
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("one", "1");
        attributes.put("two", "2");
        attributes.put("oneDecimal", "1.5");
        attributes.put("twoDecimal", "2.3");
        attributes.put("negative", "-64");
        attributes.put("negativeDecimal", "-64.1");

        // Test that errors relating to not finding methods are properly handled

        AttributeExpressionLanguageException expected = assertThrows(AttributeExpressionLanguageException.class,
                () -> verifyEquals("${math('rand'):toNumber()}", attributes, 0L));
        assertEquals("Cannot evaluate 'math' function because no subjectless method was found with the name:'rand'", expected.getMessage());

        expected = assertThrows(AttributeExpressionLanguageException.class,
                () -> verifyEquals("${negativeDecimal:math('absolute')}", attributes, 0L));
        assertEquals("Cannot evaluate 'math' function because no method was found matching the passed parameters: name:'absolute', one argument of type: 'double'", expected.getMessage());

        expected = assertThrows(AttributeExpressionLanguageException.class,
                () -> verifyEquals("${oneDecimal:math('power', ${two:toDecimal()})}", attributes, 0L));
        assertEquals("Cannot evaluate 'math' function because no method was found matching the passed parameters: name:'power', " +
                "first argument type: 'double', second argument type:  'double'", expected.getMessage());

        expected = assertThrows(AttributeExpressionLanguageException.class,
                () -> verifyEquals("${oneDecimal:math('power', ${two})}", attributes, 0L));
        assertEquals("Cannot evaluate 'math' function because no method was found matching the passed parameters: name:'power', " +
                "first argument type: 'double', second argument type:  'long'", expected.getMessage());


        // Can only verify that it runs. ToNumber() will verify that it produced a number greater than or equal to 0.0 and less than 1.0
        verifyEquals("${math('random'):toNumber()}", attributes, 0L);

        verifyEquals("${negative:math('abs')}", attributes, 64L);
        verifyEquals("${negativeDecimal:math('abs')}", attributes, 64.1D);

        verifyEquals("${negative:math('max', ${two})}", attributes, 2L);
        verifyEquals("${negativeDecimal:math('max', ${twoDecimal})}", attributes, 2.3D);

        verifyEquals("${oneDecimal:math('pow', ${two:toDecimal()})}", attributes, Math.pow(1.5,2));
        verifyEquals("${oneDecimal:math('scalb', ${two})}", attributes, Math.scalb(1.5,2));

        verifyEquals("${negative:math('abs'):toDecimal():math('cbrt'):math('max', ${two:toDecimal():math('pow',${oneDecimal}):mod(${two})})}", attributes,
                Math.max(Math.cbrt(Math.abs(-64)), Math.pow(2,1.5)%2));
    }

    @Test
    public void testMathLiteralOperations() {
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("ten", "10.1");
        attributes.put("two", "2.2");

        // The expected resulted is calculated instead of a set number due to the inaccuracy of double arithmetic
        verifyEquals("${literal(5):toNumber():multiply(${two:plus(1)})}", attributes, 5*3.2);
        verifyEquals("${literal(5.5E-1):toDecimal():plus(${literal(.5E1)}):multiply(${two:plus(1)})}", attributes, (0.55+5)*3.2);
    }

    @Test
    public void testIndexOf() {
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("attr", "https://abc.go");
        verifyEquals("${attr:indexOf('/')}", attributes, 6L);
    }

    @Test
    public void testDate() {
        final Calendar now = Calendar.getInstance();
        final int year = now.get(Calendar.YEAR);
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("entryDate", String.valueOf(now.getTimeInMillis()));

        verifyEquals("${entryDate:toNumber():toDate():format('yyyy')}", attributes, String.valueOf(year));

        // test for not existing attribute (NIFI-1962)
        assertEquals("", Query.evaluateExpressions("${notExistingAtt:toDate()}", attributes, null));

        attributes.clear();
        attributes.put("month", "3");
        attributes.put("day", "4");
        attributes.put("year", "2013");
        assertEquals("63", Query.evaluateExpressions("${year:append('/'):append(${month}):append('/'):append(${day}):toDate('yyyy/MM/dd'):format('D')}", attributes, null));
        assertEquals("63", Query.evaluateExpressions("${year:append('/'):append('${month}'):append('/'):append('${day}'):toDate('yyyy/MM/dd'):format('D')}", attributes, null));

        verifyEquals("${year:append('/'):append(${month}):append('/'):append(${day}):toDate('yyyy/MM/dd'):format('D')}", attributes, "63");
    }

    @Test
    public void testInstant() {
        final Calendar now = Calendar.getInstance();
        final int year = now.get(Calendar.YEAR);
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("entryDate", String.valueOf(now.getTimeInMillis()));

        verifyEquals("${entryDate:toNumber():toInstant():formatInstant('yyyy', 'America/New_York')}", attributes, String.valueOf(year));

        // test for not existing attribute (NIFI-1962)
        assertEquals("", Query.evaluateExpressions("${notExistingAtt:toInstant()}", attributes, null));

        attributes.clear();
        attributes.put("month", "3");
        attributes.put("day", "4");
        attributes.put("year", "2013");
        attributes.put("hour", "16");
        attributes.put("minute", "22");
        attributes.put("second", "59");

        assertEquals("63", Query.evaluateExpressions("${year:append('/'):append(${month}):append('/'):append(${day}):append(' ')" +
                ":append(${hour}):append(':'):append(${minute}):append(':'):append(${second}):toInstant('yyyy/M/d HH:mm:ss', 'America/New_York')" +
                ":formatInstant('D', 'America/New_York')}", attributes, null));

        assertEquals("63", Query.evaluateExpressions("${year:append('/'):append('${month}'):append('/'):append('${day}'):append(' ')" +
                ":append(${hour}):append(':'):append(${minute}):append(':'):append(${second}):toInstant('yyyy/M/d HH:mm:ss', 'America/New_York')" +
                ":formatInstant('D', 'America/New_York')}", attributes, null));

        verifyEquals("${year:append('/'):append(${month}):append('/'):append(${day}):append(' ')" +
                ":append(${hour}):append(':'):append(${minute}):append(':'):append(${second}):toInstant('yyyy/M/d HH:mm:ss', 'America/New_York')" +
                ":formatInstant('D', 'America/New_York')}", attributes, "63");
    }

    @Test
    public void testAnyAttribute() {
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("abc", "zzz");
        attributes.put("xyz", "abc");

        verifyEquals("${anyAttribute('abc', 'xyz', 'missingAttr'):substring(1,2):equals('b')}", attributes, true);
        verifyEquals("${anyAttribute('abc', 'xyz'):substring(1,2):equals('b')}", attributes, true);
        verifyEquals("${anyAttribute('xyz', 'abc'):substring(1,2):equals('b')}", attributes, true);
        verifyEquals("${anyAttribute('zz'):substring(1,2):equals('b')}", attributes, false);
        verifyEquals("${anyAttribute('abc', 'zz'):isNull()}", attributes, true);
    }

    @Test
    public void testAnyMatchingAttribute() {
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("abc", "zzz");
        attributes.put("xyz", "abc");
        attributes.put("123.cba", "hello");

        verifyEquals("${anyMatchingAttribute('.{2}x', '.{2}z'):substring(1,2):equals('b')}", attributes, true);
        verifyEquals("${anyMatchingAttribute('.*'):substring(1,2):equals('b')}", attributes, true);
        verifyEquals("${anyMatchingAttribute('x{44}'):substring(1,2):equals('b')}", attributes, false);
        verifyEquals("${anyMatchingAttribute('abc'):substring(1,2):equals('b')}", attributes, false);
        verifyEquals("${anyMatchingAttribute('xyz'):substring(1,2):equals('b')}", attributes, true);
        verifyEquals("${anyMatchingAttribute('xyz'):notNull()}", attributes, true);
        verifyEquals("${anyMatchingAttribute('xyz'):isNull()}", attributes, false);
        verifyEquals("${anyMatchingAttribute('xxxxxxxxx'):notNull()}", attributes, false);
        verifyEquals("${anyMatchingAttribute('123\\.c.*'):matches('hello')}", attributes, true);
        verifyEquals("${anyMatchingAttribute('123\\.c.*|a.c'):matches('zzz')}", attributes, true);
    }

    @Test
    public void testAnyDelineatedValue() {
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("abc", "a,b,c");
        attributes.put("xyz", "abc");

        final String query = "${anyDelineatedValue('${abc}', ','):equals('b')}";
        assertEquals(ResultType.BOOLEAN, Query.getResultType(query));

        assertEquals("true", Query.evaluateExpressions(query, attributes, null));
        assertEquals("true", Query.evaluateExpressions("${anyDelineatedValue('${abc}', ','):equals('a')}", attributes, null));
        assertEquals("true", Query.evaluateExpressions("${anyDelineatedValue('${abc}', ','):equals('c')}", attributes, null));
        assertEquals("false", Query.evaluateExpressions("${anyDelineatedValue('${abc}', ','):equals('d')}", attributes, null));

        verifyEquals("${anyDelineatedValue(${abc}, ','):equals('b')}", attributes, true);
        verifyEquals("${anyDelineatedValue(${abc}, ','):equals('a')}", attributes, true);
        verifyEquals("${anyDelineatedValue(${abc}, ','):equals('c')}", attributes, true);
        verifyEquals("${anyDelineatedValue(${abc}, ','):equals('d')}", attributes, false);
    }

    @Test
    public void testNestedAnyDelineatedValueOr() {
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("abc", "a,b,c");
        attributes.put("xyz", "x");

        // Assert each part separately.
        assertEquals("true", Query.evaluateExpressions("${anyDelineatedValue('${abc}', ','):equals('c')}",
                attributes, null));
        assertEquals("false", Query.evaluateExpressions("${anyDelineatedValue('${xyz}', ','):equals('z')}",
                attributes, null));

        // Combine them with 'or'.
        assertEquals("true", Query.evaluateExpressions(
                "${anyDelineatedValue('${abc}', ','):equals('c'):or(${anyDelineatedValue('${xyz}', ','):equals('z')})}",
                attributes, null));
    }

    @Test
    public void testNestedAnyDelineatedValueAnd() {
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("abc", "2,0,1,3");
        attributes.put("xyz", "x,y,z");

        // Assert each part separately.
        assertEquals("true", Query.evaluateExpressions("${anyDelineatedValue('${abc}', ','):gt('2')}",
                attributes, null));
        assertEquals("true", Query.evaluateExpressions("${anyDelineatedValue('${xyz}', ','):equals('z')}",
                attributes, null));

        // Combine them with 'and'.
        assertEquals("true", Query.evaluateExpressions(
                "${anyDelineatedValue('${abc}', ','):gt('2'):and(${anyDelineatedValue('${xyz}', ','):equals('z')})}",
                attributes, null));
    }

    @Test
    public void testAllDelineatedValues() {
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("abc", "a,b,c");
        attributes.put("xyz", "abc");

        final String query = "${allDelineatedValues('${abc}', ','):matches('[abc]')}";

        assertEquals(ResultType.BOOLEAN, Query.getResultType(query));
        assertEquals("true", Query.evaluateExpressions(query, attributes, null));
        assertEquals("true", Query.evaluateExpressions(query, attributes, null));
        assertEquals("false", Query.evaluateExpressions("${allDelineatedValues('${abc}', ','):matches('[abd]')}", attributes, null));
        assertEquals("false", Query.evaluateExpressions("${allDelineatedValues('${abc}', ','):equals('a'):not()}", attributes, null));

        verifyEquals("${allDelineatedValues(${abc}, ','):matches('[abc]')}", attributes, true);
        verifyEquals("${allDelineatedValues(${abc}, ','):matches('[abd]')}", attributes, false);
        verifyEquals("${allDelineatedValues(${abc}, ','):equals('a'):not()}", attributes, false);
    }

    @Test
    public void testAllDelineatedValuesCount() {
        final Map<String, String> attributes = new HashMap<>();

        final String query = "${allDelineatedValues('${test}', '/'):count()}";

        attributes.put("test", "/my/path");
        assertEquals(ResultType.WHOLE_NUMBER, Query.getResultType(query));
        assertEquals("3", Query.evaluateExpressions(query, attributes, null));
        assertEquals("", Query.evaluateExpressions("${test:getDelimitedField(1, '/')}", attributes, null));
        assertEquals("my", Query.evaluateExpressions("${test:getDelimitedField(2, '/')}", attributes, null));
        assertEquals("path", Query.evaluateExpressions("${test:getDelimitedField(3, '/')}", attributes, null));

        attributes.put("test", "this/is/my/path");
        assertEquals(ResultType.WHOLE_NUMBER, Query.getResultType(query));
        assertEquals("4", Query.evaluateExpressions(query, attributes, null));
        assertEquals("this", Query.evaluateExpressions("${test:getDelimitedField(1, '/')}", attributes, null));
        assertEquals("is", Query.evaluateExpressions("${test:getDelimitedField(2, '/')}", attributes, null));
        assertEquals("my", Query.evaluateExpressions("${test:getDelimitedField(3, '/')}", attributes, null));
        assertEquals("path", Query.evaluateExpressions("${test:getDelimitedField(4, '/')}", attributes, null));

        attributes.put("test", "/");
        assertEquals(ResultType.WHOLE_NUMBER, Query.getResultType(query));
        assertEquals("0", Query.evaluateExpressions(query, attributes, null));

        attributes.put("test", "path/");
        assertEquals(ResultType.WHOLE_NUMBER, Query.getResultType(query));
        assertEquals("1", Query.evaluateExpressions(query, attributes, null));
        assertEquals("path", Query.evaluateExpressions("${test:getDelimitedField(1, '/')}", attributes, null));
    }

    @Test
    public void testAllAttributes() {
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("abc", "1234");
        attributes.put("xyz", "4132");
        attributes.put("hello", "world!");

        verifyEquals("${allAttributes('abc', 'xyz'):matches('\\d+')}", attributes, true);
        verifyEquals("${allAttributes('abc', 'xyz'):toNumber():lt(99999)}", attributes, true);
        verifyEquals("${allAttributes('abc', 'hello'):length():gt(3)}", attributes, true);
        verifyEquals("${allAttributes('abc', 'hello'):length():equals(4)}", attributes, false);
        verifyEquals("${allAttributes('abc', 'xyz'):length():equals(4)}", attributes, true);
        verifyEquals("${allAttributes('abc', 'xyz', 'other'):isNull()}", attributes, false);

        assertThrows(AttributeExpressionLanguageParsingException.class, () -> Query.compile("${allAttributes('#ah'):equals('hello')"));
    }

    @Test
    public void testMathWholeNumberOperators() {
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("abc", "1234");
        attributes.put("xyz", "4132");
        attributes.put("hello", "world!");

        verifyEquals("${xyz:toNumber():gt( ${abc:toNumber()} )}", attributes, true);
    }

    @Test
    public void testMathDecimalOperators() {
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("one", "1.1");
        attributes.put("two", "2.2");
        attributes.put("one_2", "1.1");

        verifyEquals("${one:lt(${two})}", attributes, true);
        verifyEquals("${one:lt(${one_2})}", attributes, false);
        verifyEquals("${two:lt(${one})}", attributes, false);

        verifyEquals("${one:le(${two})}", attributes, true);
        verifyEquals("${one:le(${one_2})}", attributes, true);
        verifyEquals("${two:le(${one_2})}", attributes, false);

        verifyEquals("${one:ge(${two})}", attributes, false);
        verifyEquals("${one:ge(${one_2})}", attributes, true);
        verifyEquals("${two:ge(${one_2})}", attributes, true);

        verifyEquals("${one:gt(${two})}", attributes, false);
        verifyEquals("${one:gt(${one_2})}", attributes, false);
        verifyEquals("${two:gt(${one})}", attributes, true);
    }

    @Test
    public void testMathNumberDecimalConversion() {
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("xyz", "1.332");
        attributes.put("hello", "world!");

        verifyEquals("${xyz:toNumber()}", attributes, 1L);

        attributes.put("xyz", "2");
        attributes.put("hello", "world!");

        verifyEquals("${xyz:toDecimal()}", attributes, 2D);
    }

    @Test
    public void testLiteral() {
        final Map<String, String> attributes = new HashMap<>();

        verifyEquals("${literal(5)}", attributes, "5");

        verifyEquals("${literal(\"5\")}", attributes, "5");

        verifyEquals("${literal(5):toNumber()}", attributes, 5L);
        verifyEquals("${literal(5):toDecimal()}", attributes, 5D);

        verifyEquals("${literal(\"5.5\")}", attributes, "5.5");

        verifyEquals("${literal(5.5):toNumber()}", attributes, 5L);
        verifyEquals("${literal(-5.5):toNumber()}", attributes, -5L);
        verifyEquals("${literal(+5.5):toNumber()}", attributes, 5L);

        verifyEquals("${literal(5.5):toDecimal()}", attributes, 5.5D);
        verifyEquals("${literal(-5.5):toDecimal()}", attributes, -5.5D);
        verifyEquals("${literal(+5.5):toDecimal()}", attributes, 5.5D);

        verifyEquals("${literal('0xF.Fp10'):toDecimal()}", attributes, 0xF.Fp10D);

        verifyEquals("${literal('0x1234567890ABCDEF'):toNumber()}", attributes, 0x1234567890ABCDEFL);
        verifyEquals("${literal('-0x1234567890ABCDEF'):toNumber()}", attributes, -0x1234567890ABCDEFL);

        verifyEquals("${literal('-0x1234567890abcdef'):toNumber()}", attributes, -0x1234567890abcdefL);
        verifyEquals("${literal('0x1234567890abcdef'):toNumber()}", attributes, 0x1234567890abcdefL);
    }

    @Test
    public void testDecimalParsing() {
        final Map<String, String> attributes = new HashMap<>();

        // Test decimal format X.X
        verifyEquals("${literal(5.5):toDecimal()}", attributes, 5.5D);
        verifyEquals("${literal(-12.5):toDecimal()}", attributes, -12.5D);
        verifyEquals("${literal(+12.5):toDecimal()}", attributes, 12.5D);

        // Test decimal format X.XEX with positive exponent
        verifyEquals("${literal(-12.5E2):toDecimal()}", attributes, -12.5E2D);
        verifyEquals("${literal(-12.5e2):toDecimal()}", attributes, -12.5e2D);
        verifyEquals("${literal(-12.5e+2):toDecimal()}", attributes, -12.5e+2D);
        verifyEquals("${literal(12.5E+2):toDecimal()}", attributes, 12.5E+2D);
        verifyEquals("${literal(+12.5e+2):toDecimal()}", attributes, +12.5e+2D);
        verifyEquals("${literal(+12.5E2):toDecimal()}", attributes, +12.5E2D);
        verifyEquals("${literal(-12.5e2):toDecimal()}", attributes, -12.5e2D);
        verifyEquals("${literal(12.5E2):toDecimal()}", attributes, 12.5E2D);
        verifyEquals("${literal(+12.5e2):toDecimal()}", attributes, +12.5e2D);

        // Test decimal format X.XEX with negative exponent
        verifyEquals("${literal(-12.5E-2):toDecimal()}", attributes, -12.5E-2D);
        verifyEquals("${literal(12.5E-2):toDecimal()}", attributes, 12.5E-2D);
        verifyEquals("${literal(+12.5e-2):toDecimal()}", attributes, +12.5e-2D);

        // Test decimal format .X
        verifyEquals("${literal(.5):toDecimal()}", attributes, .5D);
        verifyEquals("${literal(.5):toDecimal()}", attributes, .5D);
        verifyEquals("${literal(-.5):toDecimal()}", attributes, -0.5D);
        verifyEquals("${literal(+.5):toDecimal()}", attributes, .5D);

        // Test decimal format .XEX with positive exponent
        verifyEquals("${literal(-.5E2):toDecimal()}", attributes, -.5E2D);
        verifyEquals("${literal(-.5E2):toDecimal()}", attributes, -.5E2D);
        verifyEquals("${literal(-.5e+2):toDecimal()}", attributes, -.5e+2D);
        verifyEquals("${literal(.5E+2):toDecimal()}", attributes, .5E+2D);
        verifyEquals("${literal(+.5e+2):toDecimal()}", attributes, +.5e+2D);
        verifyEquals("${literal(+.5E2):toDecimal()}", attributes, +.5E2D);
        verifyEquals("${literal(-.5e2):toDecimal()}", attributes, -.5e2D);
        verifyEquals("${literal(.5E2):toDecimal()}", attributes, .5E2D);
        verifyEquals("${literal(+.5e2):toDecimal()}", attributes, +.5e2D);

        // Test decimal format .XEX with negative exponent
        verifyEquals("${literal(-.5E-2):toDecimal()}", attributes, -.5E-2D);
        verifyEquals("${literal(.5e-2):toDecimal()}", attributes, .5e-2D);
        verifyEquals("${literal(+.5E-2):toDecimal()}", attributes, +.5E-2D);

        // Verify allowed values
        verifyEquals("${literal(9876543210.0123456789e123):toDecimal()}", attributes, 9876543210.0123456789e123D);

        verifyEmpty("${literal('A.1e123'):toDecimal()}", attributes);
        verifyEmpty("${literal('0.Ae123'):toDecimal()}", attributes);
        verifyEmpty("${literal('0.1eA'):toDecimal()}", attributes);

        // --------- Hex format ------//

        // Test Hex format X.
        verifyEquals("${literal('0xF1.p2'):toDecimal()}", attributes, 0xF1.p2D);
        verifyEquals("${literal('+0xF1.P2'):toDecimal()}", attributes, +0xF1.p2D);
        verifyEquals("${literal('-0xF1.p2'):toDecimal()}", attributes, -0xF1.p2D);

        // Test Hex format X.XEX with positive exponent
        verifyEquals("${literal('-0xF1.5Bp2'):toDecimal()}", attributes, -0xF1.5Bp2D);
        verifyEquals("${literal('-0xF1.5BP2'):toDecimal()}", attributes, -0xF1.5BP2D);
        verifyEquals("${literal('-0xF1.5BP+2'):toDecimal()}", attributes, -0xF1.5Bp+2D);
        verifyEquals("${literal('0xF1.5BP+2'):toDecimal()}", attributes, 0xF1.5BP+2D);
        verifyEquals("${literal('+0xF1.5Bp+2'):toDecimal()}", attributes, +0xF1.5Bp+2D);
        verifyEquals("${literal('+0xF1.5BP2'):toDecimal()}", attributes, +0xF1.5BP2D);
        verifyEquals("${literal('-0xF1.5Bp2'):toDecimal()}", attributes, -0xF1.5Bp2D);
        verifyEquals("${literal('0xF1.5BP2'):toDecimal()}", attributes, 0xF1.5BP2D);
        verifyEquals("${literal('+0xF1.5Bp2'):toDecimal()}", attributes, +0xF1.5Bp2D);

        // Test decimal format X.XEX with negative exponent
        verifyEquals("${literal('-0xF1.5BP-2'):toDecimal()}", attributes, -0xF1.5BP-2D);
        verifyEquals("${literal('0xF1.5BP-2'):toDecimal()}", attributes, 0xF1.5BP-2D);
        verifyEquals("${literal('+0xF1.5Bp-2'):toDecimal()}", attributes, +0xF1.5Bp-2D);

        // Test decimal format .XEX with positive exponent
        verifyEquals("${literal('0x.5BP0'):toDecimal()}", attributes, 0x.5BP0D);
        verifyEquals("${literal('-0x.5BP0'):toDecimal()}", attributes, -0x.5BP0D);
        verifyEquals("${literal('-0x.5BP+2'):toDecimal()}", attributes, -0x.5BP+2D);
        verifyEquals("${literal('0x.5BP+2'):toDecimal()}", attributes, 0x.5BP+2D);
        verifyEquals("${literal('+0x.5Bp+2'):toDecimal()}", attributes, +0x.5Bp+2D);
        verifyEquals("${literal('+0x.5BP2'):toDecimal()}", attributes, +0x.5BP2D);
        verifyEquals("${literal('-0x.5Bp2'):toDecimal()}", attributes, -0x.5Bp2D);
        verifyEquals("${literal('0x.5BP2'):toDecimal()}", attributes, 0x.5BP2D);
        verifyEquals("${literal('+0x.5Bp+2'):toDecimal()}", attributes, +0x.5Bp2D);

        // Test decimal format .XEX with negative exponent
        verifyEquals("${literal('-0x.5BP-2'):toDecimal()}", attributes, -0x.5BP-2D);
        verifyEquals("${literal('0x.5Bp-2'):toDecimal()}", attributes, 0x.5Bp-2D);
        verifyEquals("${literal('+0x.5BP-2'):toDecimal()}", attributes, +0x.5BP-2D);

        // Verify allowed values
        verifyEquals("${literal('0xFEDCBA9876543210.0123456789ABCDEFp123'):toDecimal()}", attributes, 0xFEDCBA9876543210.0123456789ABCDEFp123D);
        verifyEquals("${literal('0xfedcba9876543210.0123456789abcdefp123'):toDecimal()}", attributes, 0xfedcba9876543210.0123456789abcdefp123D);
        verifyEmpty("${literal('0xG.1p123'):toDecimal()}", attributes);
        verifyEmpty("${literal('0x1.Gp123'):toDecimal()}", attributes);
        verifyEmpty("${literal('0x1.1pA'):toDecimal()}", attributes);
        verifyEmpty("${literal('0x1.1'):toDecimal()}", attributes);

        // Special cases
        verifyEquals("${literal('" + POSITIVE_INFINITY + "'):toDecimal():plus(1):plus(2)}", attributes, POSITIVE_INFINITY);
        verifyEquals("${literal('" + NEGATIVE_INFINITY + "'):toDecimal():plus(1):plus(2)}", attributes, NEGATIVE_INFINITY);
        verifyEquals("${literal('" + NaN + "'):toDecimal():plus(1):plus(2)}", attributes, NaN);
    }

    @Test
    public void testAllMatchingAttributes() {
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("abc", "1234");
        attributes.put("xyz", "4132");
        attributes.put("hello", "world!");
        attributes.put("123.cba", "hell.o");

        verifyEquals("${'123.cba':matches('hell\\.o')}", attributes, true);
        verifyEquals("${allMatchingAttributes('123\\.cba'):equals('hell.o')}", attributes, true);
        verifyEquals("${allMatchingAttributes('(abc|xyz)'):matches('\\d+')}", attributes, true);
        verifyEquals("${allMatchingAttributes('[ax].*'):toNumber():lt(99999)}", attributes, true);
        verifyEquals("${allMatchingAttributes('hell.'):length():gt(3)}", attributes, true);

        verifyEquals("${allMatchingAttributes('123\\.cba'):equals('no')}", attributes, false);
    }

    @Test
    public void testMatches() {
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("abc", "1234xyz4321");
        attributes.put("end", "xyz");
        attributes.put("xyz", "4132");
        attributes.put("hello", "world!");
        attributes.put("dotted", "abc.xyz");

        final String evaluated = Query.evaluateExpressions("${abc:matches('1234${end}4321')}", attributes, null);
        assertEquals("true", evaluated);

        attributes.put("end", "888");
        final String secondEvaluation = Query.evaluateExpressions("${abc:matches('1234${end}4321')}", attributes, null);
        assertEquals("false", secondEvaluation);

        verifyEquals("${dotted:matches('abc\\.xyz')}", attributes, true);

        // Test for matches(null)
        assertEquals("false", Query.evaluateExpressions("${abc:matches(${not.here})}", attributes, null));
    }

    @Test
    public void testFind() {
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("abc", "1234xyz4321");
        attributes.put("end", "xyz");
        attributes.put("xyz", "4132");
        attributes.put("hello", "world!");
        attributes.put("dotted", "abc.xyz");

        final String evaluated = Query.evaluateExpressions("${abc:find('1234${end}4321')}", attributes, null);
        assertEquals("true", evaluated);

        attributes.put("end", "888");

        final String secondEvaluation = Query.evaluateExpressions("${abc:find('${end}4321')}", attributes, null);
        assertEquals("false", secondEvaluation);

        verifyEquals("${dotted:find('\\.')}", attributes, true);

        // Test for find(null)
        assertEquals("false", Query.evaluateExpressions("${abc:find(${not.here})}", attributes, null));
    }

    @Test
    public void testSubstringAfter() {
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("filename", "file-255");

        verifyEquals("${filename:substringAfter('')}", attributes, "file-255");
        verifyEquals("${filename:substringAfterLast('')}", attributes, "file-255");
        verifyEquals("${filename:substringBefore('')}", attributes, "file-255");
        verifyEquals("${filename:substringBeforeLast('')}", attributes, "file-255");
        verifyEquals("${filename:substringBefore('file')}", attributes, "");

        attributes.put("uri", "sftp://some.uri");
        verifyEquals("${uri:substringAfter('sftp')}", attributes, "://some.uri");
    }

    @Test
    public void testSubstringAfterLast() {
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("filename", "file-file-255");

        verifyEquals("${filename:substringAfterLast('file-')}", attributes, "255");
        verifyEquals("${filename:substringAfterLast('5')}", attributes, "");
        verifyEquals("${filename:substringAfterLast('x')}", attributes, "file-file-255");
    }

    @Test
    public void testSubstringBefore() {
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("something", "some {} or other");

        verifyEquals("${something:substringBefore('}')}", attributes, "some {");
    }

    @Test
    public void testSubstring() {
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("filename", "file-255");

        verifyEquals("${filename:substring(1, 2)}", attributes, "i");
        verifyEquals("${filename:substring(4)}", attributes, "-255");
    }

    @Test
    public void testSubstringOOB() {
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("filename", "file-255");

        verifyEquals("${filename:substring(10, 20)}", attributes, "");
        verifyEquals("${filename:substring(10)}", attributes, "");
        verifyEquals("${filename:substring(-2)}", attributes, "");
        verifyEquals("${filename:substring(2, -2)}", attributes, "");
    }

    @Test
    public void testToRadix() {
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("filename", "file-255");
        attributes.put("filename2", "file-99999");

        verifyEquals("${filename:substringAfter('-'):toNumber():toRadix(16):toUpper()}", attributes, "FF");
        verifyEquals("${filename:substringAfter('-'):toNumber():toRadix(16, 4):toUpper()}", attributes, "00FF");
        verifyEquals("${filename:substringAfter('-'):toNumber():toRadix(36, 3):toUpper()}", attributes, "073");
    }

    @Test
    public void testFromRadix() {
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("test1", "ABCDEF");
        attributes.put("test2", "123");

        verifyEquals("${test1:fromRadix(16)}", attributes, 0xABCDEFL);
        verifyEquals("${test2:fromRadix(4)}", attributes, 27L);
    }

    @Test
    public void testBase64Encode(){
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("userpass", "admin:admin");
        verifyEquals("${userpass:base64Encode()}", attributes, "YWRtaW46YWRtaW4=");

    }

    @Test
    public void testBase64Decode(){
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("userpassbase64", "YWRtaW46YWRtaW4=");
        verifyEquals("${userpassbase64:base64Decode()}", attributes, "admin:admin");

    }

    @Test
    public void testDateFormatConversion() {
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("blue", "20130917162643");
        verifyEquals("${blue:toDate('yyyyMMddHHmmss'):format(\"yyyy/MM/dd HH:mm:ss.SSS'Z'\")}", attributes, "2013/09/17 16:26:43.000Z");
    }

    @Test
    public void testDateFormatConversionWithTimeZone() {
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("blue", "20130917162643");
        verifyEquals("${blue:toDate('yyyyMMddHHmmss', 'GMT'):format(\"yyyy/MM/dd HH:mm:ss.SSS'Z'\", 'GMT')}", attributes, "2013/09/17 16:26:43.000Z");
        verifyEquals("${blue:toDate('yyyyMMddHHmmss', 'GMT'):format(\"yyyy/MM/dd HH:mm:ss.SSS'Z'\", 'Europe/Paris')}", attributes, "2013/09/17 18:26:43.000Z");
        verifyEquals("${blue:toDate('yyyyMMddHHmmss', 'GMT'):format(\"yyyy/MM/dd HH:mm:ss.SSS'Z'\", 'America/Los_Angeles')}", attributes, "2013/09/17 09:26:43.000Z");
    }

    @Test
    public void testInstantFormatConversion() {
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("blue", "20130917162643");
        verifyEquals("${blue:toInstant('yyyyMMddHHmmss', 'GMT'):formatInstant(\"yyyy/MM/dd HH:mm:ss.SSS'Z'\", 'GMT')}", attributes, "2013/09/17 16:26:43.000Z");
        verifyEquals("${blue:toInstant('yyyyMMddHHmmss', 'GMT'):formatInstant(\"yyyy/MM/dd HH:mm:ss.SSS'Z'\", 'Europe/Paris')}", attributes, "2013/09/17 18:26:43.000Z");
        verifyEquals("${blue:toInstant('yyyyMMddHHmmss', 'GMT'):formatInstant(\"yyyy/MM/dd HH:mm:ss.SSS'Z'\", 'America/Los_Angeles')}", attributes, "2013/09/17 09:26:43.000Z");
    }

    @Test
    public void testNot() {
        verifyEquals("${ab:notNull():not()}", new HashMap<>(), true);
    }

    @Test
    public void testAttributesWithSpaces() {
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("ab", "abc");
        attributes.put("a  b", "abc");

        verifyEquals("${ab}", attributes, "abc");
        verifyEquals("${'a  b'}", attributes, "abc");
        verifyEquals("${'a b':replaceNull('')}", attributes, "");
    }

    @Test
    public void testOr() {
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("filename1", "xabc");
        attributes.put("filename2", "yabc");
        attributes.put("filename3", "abcxy");

        verifyEquals("${filename1:startsWith('x'):or(true)}", attributes, true);
        verifyEquals("${filename1:startsWith('x'):or( ${filename1:startsWith('y')} )}", attributes, true);
        verifyEquals("${filename2:startsWith('x'):or( ${filename2:startsWith('y')} )}", attributes, true);
        verifyEquals("${filename3:startsWith('x'):or( ${filename3:startsWith('y')} )}", attributes, false);
        verifyEquals("${filename1:startsWith('x'):or( ${filename2:startsWith('y')} )}", attributes, true);
        verifyEquals("${filename2:startsWith('x'):or( ${filename1:startsWith('y')} )}", attributes, false);
    }

    @Test
    public void testAnd() {
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("filename1", "xabc");
        attributes.put("filename2", "yabc");
        attributes.put("filename 3", "abcxy");

        verifyEquals("${filename1:startsWith('x'):and(true)}", attributes, true);
        verifyEquals("${filename1:startsWith('x') : and( false )}", attributes, false);
        verifyEquals("${filename1:startsWith('x'):and( ${filename1:startsWith('y')} )}", attributes, false);
        verifyEquals("${filename2:startsWith('x'):and( ${filename2:startsWith('y')} )}", attributes, false);
        verifyEquals("${filename3:startsWith('x'):and( ${filename3:startsWith('y')} )}", attributes, false);
        verifyEquals("${filename1:startsWith('x'):and( ${filename2:startsWith('y')} )}", attributes, true);
        verifyEquals("${filename2:startsWith('x'):and( ${filename1:startsWith('y')} )}", attributes, false);
        verifyEquals("${filename1:startsWith('x'):and( ${'filename 3':endsWith('y')} )}", attributes, true);
    }

    @Test
    public void testAndOrNot() {
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("filename1", "xabc");
        attributes.put("filename2", "yabc");
        attributes.put("filename 3", "abcxy");

        final String query
                = "${"
                + "     'non-existing':notNull():not():and("
                + // true AND (
                "     ${filename1:startsWith('y')"
                + // false
                "     :or("
                + // or
                "       ${ filename1:startsWith('x'):and(false) }"
                + // false
                "     ):or("
                + // or
                "       ${ filename2:endsWith('xxxx'):or( ${'filename 3':length():gt(1)} ) }"
                + // true )
                "     )}"
                + "     )"
                + "}";

        verifyEquals(query, attributes, true);
    }

    @Test
    public void testAndOrLogicWithAnyAll() {
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("filename1", "xabc");
        attributes.put("filename2", "yabc");
        attributes.put("filename 3", "abcxy");

        verifyEquals("${anyMatchingAttribute('filename.*'):contains('abc'):and( ${filename2:equals('yabc')} )}", attributes, true);
        verifyEquals("${anyMatchingAttribute('filename.*'):contains('abc'):and( ${filename2:equals('xabc')} )}", attributes, false);
        verifyEquals("${anyMatchingAttribute('filename.*'):contains('abc'):not():or( ${filename2:equals('yabc')} )}", attributes, true);
        verifyEquals("${anyMatchingAttribute('filename.*'):contains('abc'):not():or( ${filename2:equals('xabc')} )}", attributes, false);
    }

    @Test
    public void testKeywords() {
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("UUID", "123");
        verifyEquals("${ 'UUID':toNumber():equals(123) }", attributes, true);
    }

    @Test
    public void testEqualsNumber() {
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("abc", "123");
        verifyEquals("${ abc:toNumber():equals(123) }", attributes, true);
    }

    @Test
    public void testIn() {
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("myEnum", "JOHN");
        verifyEquals("${ myEnum:in('PAUL', 'JOHN', 'MIKE') }", attributes, true);
        verifyEquals("${ myEnum:in('RED', 'BLUE', 'GREEN') }", attributes, false);

        attributes.put("toReplace", "BLUE");
        verifyEquals("${ myEnum:in('RED', ${ toReplace:replace('BLUE', 'JOHN') }, 'GREEN') }", attributes, true);
    }

    @Test
    public void testSubjectAsEmbeddedExpressionWithSurroundChars() {
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("b", "x");
        attributes.put("abcxcba", "hello");

        final String evaluated = Query.evaluateExpressions("${ 'abc${b}cba':substring(0, 1) }", attributes, null);
        assertEquals("h", evaluated);
    }

    @Test
    public void testToNumberFunctionReturnsNumberType() {
        assertEquals(ResultType.WHOLE_NUMBER, Query.getResultType("${header.size:toNumber()}"));
    }

    @Test
    public void testRandomFunctionReturnsNumberType() {
        assertEquals(ResultType.WHOLE_NUMBER, Query.getResultType("${random()}"));
    }

    @Test
    public void testAnyAttributeEmbedded() {
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("a1", "test1");
        attributes.put("b2", "2test");
        attributes.put("c3", "3test3");

        final String query = "${a1:equals('test1'):and( ${anyAttribute('a1','b2','c3'):contains('2')})}";
        verifyEquals(query, attributes, true);
    }

    @Test
    public void testEvaluateWithinCurlyBraces() {
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("abc", "xyz");

        final String query = "{ ${abc} }";
        final List<String> expressions = Query.extractExpressions(query);
        assertEquals(1, expressions.size());
        assertEquals("${abc}", expressions.get(0));
        assertEquals("{ xyz }", Query.evaluateExpressions(query, attributes, ParameterLookup.EMPTY));
    }

    @Test
    public void testGetStateValue() {
        final Map<String, String> stateValues = new HashMap<>();
        stateValues.put("abc", "xyz");
        stateValues.put("123", "qwe");
        stateValues.put("true", "asd");
        stateValues.put("iop", "098");

        final Map<String, String> attributes = new HashMap<>();
        attributes.put("abc", "iop");
        attributes.put("4321", "123");
        attributes.put("false", "bnm");

        String query = "${getStateValue('abc')}";
        verifyEquals(query, attributes, stateValues, "xyz");

        query = "${getStateValue(${'4321':toString()})}";
        verifyEquals(query, attributes, stateValues, "qwe");

        query = "${getStateValue(${literal(true):toString()})}";
        verifyEquals(query, attributes, stateValues, "asd");

        query = "${getStateValue(${abc}):equals('098')}";
        verifyEquals(query, attributes, stateValues, true);
    }

    @Test
    public void testLiteralFunction() {
        final Map<String, String> attrs = Collections.emptyMap();
        verifyEquals("${literal(2):gt(1)}", attrs, true);
        verifyEquals("${literal('hello'):substring(0, 1):equals('h')}", attrs, true);
    }

    @Test
    public void testRandomFunction() {
        final Map<String, String> attrs = Collections.emptyMap();
        final Long negOne = -1L;
        final HashSet<Long> results = new HashSet<>(100);
        for (int i = 0; i < results.size(); i++) {
            long result = (Long) getResult("${random()}", attrs).getValue();
            assertThat("random", result, greaterThan(negOne));
            assertTrue(results.add(result), "duplicate random");
        }
    }

    QueryResult<?> getResult(String expr, Map<String, String> attrs) {
        final Query query = Query.compile(expr);
        return query.evaluate(new StandardEvaluationContext(attrs));
    }

    @Test
    public void testFunctionAfterReduce() {
        // Cannot call gt(2) after count() because count() is a 'reducing function'
        // and must be the last function in an expression.
        assertFalse(Query.isValidExpression("${allMatchingAttributes('a.*'):contains('2'):count():gt(2)}"));

        final Map<String, String> attributes = new HashMap<>();
        attributes.put("a.1", "245");
        attributes.put("a.2", "123");
        attributes.put("a.3", "732");
        attributes.put("a.4", "343");
        attributes.put("a.5", "553");

        final String endsWithCount = "${allMatchingAttributes('a.*'):contains('2'):count()}";
        assertTrue(Query.isValidExpression(endsWithCount));
        verifyEquals(endsWithCount, attributes, 3L);

        // in order to check if value is greater than 2, need to first evaluate the
        // 'aggregate' and 'reducing' functions as an inner expression. Then we can
        // use the literal() function to make the result of the inner expression the subject
        // of the function gt()
        final String usingLiteral = "${literal(" + endsWithCount + "):gt(2)}";
        assertTrue(Query.isValidExpression(usingLiteral));
        verifyEquals(usingLiteral, attributes, true);

        attributes.clear();
        attributes.put("a1", "123");
        attributes.put("a2", "321");
        verifyEquals("${allMatchingAttributes('a.*'):contains('2')}", attributes, true);
        verifyEquals("${allMatchingAttributes('a.*'):contains('2'):toUpper():equals('TRUE')}", attributes, true);
        verifyEquals("${allMatchingAttributes('a.*'):contains('2'):equals('true'):and( ${literal(true)} )}", attributes, true);
    }

    @Test
    public void testGetDelimitedField() {
        final Map<String, String> attributes = new HashMap<>();

        attributes.put("line", "Name, Age, Title");

        // Test "simple" case - comma separated with no quoted or escaped text
        verifyEquals("${line:getDelimitedField(1)}", attributes, "Name");
        verifyEquals("${line:getDelimitedField(1, ',')}", attributes, "Name");
        verifyEquals("${line:getDelimitedField(1, ',', '\"')}", attributes, "Name");
        verifyEquals("${line:getDelimitedField(1, ',', '\"', '\\\\')}", attributes, "Name");

        verifyEquals("${line:getDelimitedField(2)}", attributes, " Age");
        verifyEquals("${line:getDelimitedField(2, ',')}", attributes, " Age");
        verifyEquals("${line:getDelimitedField(2, ',', '\"')}", attributes, " Age");
        verifyEquals("${line:getDelimitedField(2, ',', '\"', '\\\\')}", attributes, " Age");

        verifyEquals("${line:getDelimitedField(3)}", attributes, " Title");
        verifyEquals("${line:getDelimitedField(3, ',')}", attributes, " Title");
        verifyEquals("${line:getDelimitedField(3, ',', '\"')}", attributes, " Title");
        verifyEquals("${line:getDelimitedField(3, ',', '\"', '\\\\')}", attributes, " Title");

        // test with a space in column
        attributes.put("line", "First Name, Age, Title");
        verifyEquals("${line:getDelimitedField(1)}", attributes, "First Name");
        verifyEquals("${line:getDelimitedField(1, ',')}", attributes, "First Name");
        verifyEquals("${line:getDelimitedField(1, ',', '\"')}", attributes, "First Name");
        verifyEquals("${line:getDelimitedField(1, ',', '\"', '\\\\')}", attributes, "First Name");

        // test quoted value
        attributes.put("line", "\"Name (Last, First)\", Age, Title");
        verifyEquals("${line:getDelimitedField(1)}", attributes, "\"Name (Last, First)\"");
        verifyEquals("${line:getDelimitedField(1, ',')}", attributes, "\"Name (Last, First)\"");
        verifyEquals("${line:getDelimitedField(1, ',', '\"')}", attributes, "\"Name (Last, First)\"");
        verifyEquals("${line:getDelimitedField(1, ',', '\"', '\\\\')}", attributes, "\"Name (Last, First)\"");

        // test non-standard quote char
        attributes.put("line", "_Name (Last, First)_, Age, Title");
        verifyEquals("${line:getDelimitedField(1)}", attributes, "_Name (Last");
        verifyEquals("${line:getDelimitedField(1, ',', '_')}", attributes, "_Name (Last, First)_");

        // test escape char
        attributes.put("line", "Name (Last\\, First), Age, Title");
        verifyEquals("${line:getDelimitedField(1)}", attributes, "Name (Last\\, First)");

        attributes.put("line", "Name (Last__, First), Age, Title");
        verifyEquals("${line:getDelimitedField(1, ',', '\"', '_')}", attributes, "Name (Last__");

        attributes.put("line", "Name (Last_, First), Age, Title");
        verifyEquals("${line:getDelimitedField(1, ',', '\"', '_')}", attributes, "Name (Last_, First)");

        // test escape for enclosing chars
        attributes.put("line", "\\\"Name (Last, First), Age, Title");
        verifyEquals("${line:getDelimitedField(1)}", attributes, "\\\"Name (Last");

        // get non existing field
        attributes.put("line", "Name, Age, Title");
        verifyEquals("${line:getDelimitedField(12)}", attributes, "");

        // test escape char within quotes
        attributes.put("line", "col 1, col 2, \"The First, Second, and \\\"Last\\\" Column\", Last");
        verifyEquals("${line:getDelimitedField(3):trim()}", attributes, "\"The First, Second, and \\\"Last\\\" Column\"");

        // test stripping chars
        attributes.put("line", "col 1, col 2, \"The First, Second, and \\\"Last\\\" Column\", Last");
        verifyEquals("${line:getDelimitedField(3, ',', '\"', '\\\\', true):trim()}", attributes, "The First, Second, and \"Last\" Column");

        attributes.put("line", "\"Jacobson, John\", 32, Mr.");
        verifyEquals("${line:getDelimitedField(2)}", attributes, " 32");
    }

    @Test
    public void testEscapeFunctions() {
        final Map<String, String> attributes = new HashMap<>();

        attributes.put("string", "making air \"QUOTES\".");
        verifyEquals("${string:escapeJson()}", attributes, "making air \\\"QUOTES\\\".");

        attributes.put("string", "M & M");
        verifyEquals("${string:escapeXml()}", attributes, "M &amp; M");

        attributes.put("string", "making air \"QUOTES\".");
        verifyEquals("${string:escapeCsv()}", attributes, "\"making air \"\"QUOTES\"\".\"");

        attributes.put("string", "special ¡");
        verifyEquals("${string:escapeHtml3()}", attributes, "special &iexcl;");

        attributes.put("string", "special ♣");
        verifyEquals("${string:escapeHtml4()}", attributes, "special &clubs;");
      }

    @Test
    public void testUnescapeFunctions() {
        final Map<String, String> attributes = new HashMap<>();

        attributes.put("string", "making air \\\"QUOTES\\\".");
        verifyEquals("${string:unescapeJson()}", attributes, "making air \"QUOTES\".");

        attributes.put("string", "M &amp; M");
        verifyEquals("${string:unescapeXml()}", attributes, "M & M");

        attributes.put("string", "\"making air \"\"QUOTES\"\".\"");
        verifyEquals("${string:unescapeCsv()}", attributes, "making air \"QUOTES\".");

        attributes.put("string", "special &iexcl;");
        verifyEquals("${string:unescapeHtml3()}", attributes, "special ¡");

        attributes.put("string", "special &clubs;");
        verifyEquals("${string:unescapeHtml4()}", attributes, "special ♣");
    }

    @Test
    public void testIfElse() {
        final Map<String, String> attributes = new HashMap<>();
        verifyEquals("${attr:isNull():ifElse('a', 'b')}", attributes, "a");
        verifyEquals("${attr:ifElse('a', 'b')}", attributes, "b");
        attributes.put("attr", "hello");
        verifyEquals("${attr:isNull():ifElse('a', 'b')}", attributes, "b");
        verifyEquals("${attr:ifElse('a', 'b')}", attributes, "b");
        attributes.put("attr", "true");
        verifyEquals("${attr:ifElse('a', 'b')}", attributes, "a");

        verifyEquals("${attr2:isNull():ifElse('a', 'b')}", attributes, "a");
        verifyEquals("${literal(true):ifElse('a', 'b')}", attributes, "a");
        verifyEquals("${literal(true):ifElse(false, 'b')}", attributes, "false");
    }

    @Test
    public void testHash() {
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("str_attr", "string value");
        attributes.put("nbr_attr", "10");
        verifyEquals("${literal('john'):hash('MD5')}", attributes, "527bd5b5d689e2c32ae974c6229ff785");
        verifyEquals("${str_attr:hash('MD5')}", attributes, "64e58419496c7248b4ef25731f88b8c3");
        verifyEquals("${str_attr:hash('SHA-1')}", attributes, "34990db823e7bb2b47278a7fbf08c62d9e8e4307");
        verifyEquals("${str_attr:hash('SHA-256')}", attributes, "9b6a1a9167a5caf3f5948413faa89e0ec0de89e12bef55327442e60dcc0e8c9b");
        verifyEquals("${nbr_attr:toNumber():hash('MD5')}", attributes, "d3d9446802a44259755d38e6d163e820");
        verifyEquals("${nbr_attr:hash('MD5')}", attributes, "d3d9446802a44259755d38e6d163e820");
    }

    @Test
    public void testHashFailure() {
        final Map<String, String> attributes = new HashMap<>();
        assertThrows(AttributeExpressionLanguageException.class,
                () -> verifyEquals("${literal('john'):hash('NOT_A_ALGO')}", attributes, "527bd5b5d689e2c32ae974c6229ff785"));
    }

    @Test
    public void testThread() {
        final Map<String, String> attributes = new HashMap<>();
        verifyEquals("${thread()}", attributes, "main");
    }

    @Test
    public void testPadLeft() {
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("attr", "hello");
        attributes.put("emptyString", "");
        attributes.put("nullString", null);

        verifyEquals("${attr:padLeft(10, '@')}", attributes, "@@@@@hello");
        verifyEquals("${attr:padLeft(10)}", attributes, "_____hello");
        verifyEquals("${attr:padLeft(10, \"xy\")}", attributes, "xyxyxhello");
        verifyEquals("${attr:padLeft(10, \"aVeryLongPaddingString\")}", attributes, "aVeryhello");
        verifyEquals("${attr:padLeft(1, \"a\")}", attributes, "hello");
        verifyEquals("${attr:padLeft(-10, \"a\")}", attributes, "hello");
        verifyEquals("${emptyString:padLeft(10, '@')}", attributes, "@@@@@@@@@@");
        verifyEquals("${attr:padLeft(9999999999, \"abc\")}", attributes, "hello");
        verifyEmpty("${nonExistingAttr:padLeft(10, \"abc\")}", attributes);
        verifyEmpty("${nullString:padLeft(10, \"@\")}", attributes);
    }

    @Test
    public void testPadRight() {
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("attr", "hello");
        attributes.put("emptyString", "");
        attributes.put("nullString", null);

        verifyEquals("${attr:padRight(10, '@')}", attributes, "hello@@@@@");
        verifyEquals("${attr:padRight(10)}", attributes, "hello_____");
        verifyEquals("${attr:padRight(10, \"xy\")}", attributes, "helloxyxyx");
        verifyEquals("${attr:padRight(10, \"aVeryLongPaddingString\")}", attributes, "helloaVery");
        verifyEquals("${attr:padRight(1, \"a\")}", attributes, "hello");
        verifyEquals("${attr:padRight(-10, \"a\")}", attributes, "hello");
        verifyEquals("${emptyString:padRight(10, '@')}", attributes, "@@@@@@@@@@");
        verifyEquals("${attr:padRight(9999999999, \"abc\")}", attributes, "hello");
        verifyEmpty("${nonExistingAttr:padRight(10, \"abc\")}", attributes);
        verifyEmpty("${nullString:padRight(10, \"@\")}", attributes);
    }

    @Test
    public void testRepeat() {
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("str", "abc");

        verifyEquals("${not_exist:repeat(1, 2)}", attributes, "");
        verifyEquals("${str:repeat(1, 1)}", attributes, "abc");

        // Custom verify because the result could be one of multiple options
        String multipleResultExpression = "${str:repeat(1, 3)}";
        String multipleResultExpectedResult1 = "abc";
        String multipleResultExpectedResult2 = "abcabc";
        String multipleResultExpectedResult3 = "abcabcabc";
        List<String> multipleResultExpectedResults = Arrays.asList(multipleResultExpectedResult1, multipleResultExpectedResult2, multipleResultExpectedResult3);
        Query.validateExpression(multipleResultExpression, false);
        final String actualResult = Query.evaluateExpressions(multipleResultExpression, attributes, null, null, ParameterLookup.EMPTY);
        assertTrue(multipleResultExpectedResults.contains(actualResult));

        verifyEquals("${str:repeat(4)}", attributes, "abcabcabcabc");
        assertThrows(AttributeExpressionLanguageException.class, () -> verifyEquals("${str:repeat(-1)}", attributes, ""));
        assertThrows(AttributeExpressionLanguageException.class, () -> verifyEquals("${str:repeat(0)}", attributes, ""));
        assertThrows(AttributeExpressionLanguageException.class, () -> verifyEquals("${str:repeat(2,1)}", attributes, ""));
    }

    @Test
    public void testUuidsWithNamespace() {
        // Testing a lot of cases here b/c it's a custom UUID3/5 implementation

        final Map<String, String> attributes = new HashMap<>();
        attributes.put("myattr0", "u5IkOYFFvYioYBJSNI2XNjPaVoRjXYnr");
        attributes.put("myattr1", "mSDgSKQrY67QCTPatV5qHrZa4oUQ2wEX");
        attributes.put("myattr2", "u6jH7pF8iAqwjr42i3r5DubdNcgwqEaX");
        attributes.put("myattr3", "9eDG1KbqvHrtIMSmvH44t0K7fHXs7xtz");
        attributes.put("myattr4", "QeAUDsMYoHJHLsy1BPPSmQWKhCKvwEpj");
        attributes.put("myattr5", "U5Cw4b79SW1YiB5Va3DfUMI9y4iJwnVS");
        attributes.put("myattr6", "Ig51Jl3EtwaKlVo9MnDSDdJSlXMgZ1It");
        attributes.put("myattr7", "F2iLLLHXgliEpIDwJ4JcqeWBVi70cHS6");
        attributes.put("myattr8", "1BFShkKLOcjwn1GMsyO4Fmb0iNTVt2Tf");
        attributes.put("myattr9", "WxiyO8Gzw0jQnBlYeZMcdNTwCWJe5MNg");
        attributes.put("myattr10", null);

        // Version 3s
        verifyEquals("${myattr0:UUID3('b9e81de3-7047-4b5e-a822-8fff5b49f808')}", attributes, "7ab88cc4-7748-3214-812a-1bc4500a911a");
        verifyEquals("${myattr1:UUID3('341857cc-c5f3-4f76-b336-169f81e9dc7a')}", attributes, "d788c2df-95e1-33aa-a548-9be42f222909");
        verifyEquals("${myattr2:UUID3('27e35966-52c9-48ba-bc91-3894a2f164d8')}", attributes, "e960f7af-5eec-3298-8512-e3933836bd5a");
        verifyEquals("${myattr3:UUID3('1aef683a-2c0b-4f0e-9287-792361873e8f')}", attributes, "bf1727d8-93d3-3550-9071-78c8686f30c3");
        verifyEquals("${myattr4:UUID3('5f15efac-e274-42b1-8d0f-15c2c97acb7d')}", attributes, "9e68a780-090d-30a9-903c-22cf7eb5c511");
        verifyEquals("${myattr5:UUID3('ebd71811-fd78-4929-856b-4cec7a38d666')}", attributes, "a2a4b1b5-d93f-3656-be0d-f1db281060c1");
        verifyEquals("${myattr6:UUID3('7b1bce89-f12b-4b56-afb8-f9b0a1334926')}", attributes, "8eea2153-d42e-3f63-892c-33ff7f0be389");
        verifyEquals("${myattr7:UUID3('fe085c56-95e2-4cf8-8612-ba878ed35f0b')}", attributes, "3cd6470f-5432-3599-82ce-1f2b22adcec6");
        verifyEquals("${myattr8:UUID3('2be146a5-f54e-4ca1-a10d-d219e9fc6c6f')}", attributes, "c3ed8ced-b32f-39da-a7ea-75934f419446");
        verifyEquals("${myattr9:UUID3('4939d5dd-51c1-4d0e-badd-77fa7c7eebc1')}", attributes, "6507198b-f565-3196-9123-6f946f8c53bc");
        verifyEmpty("${myattr10:UUID3('4939d5dd-51c1-4d0e-badd-77fa7c7eebc1')}", attributes);
        verifyEmpty("${myattr11:UUID3('4939d5dd-51c1-4d0e-badd-77fa7c7eebc1')}", attributes);
        verifyEquals("${myattr9:UUID3(${myattr11})}", attributes, "f2d25da2-cc06-34de-80a3-cf64aff82020");

        // Version 5s
        verifyEquals("${myattr0:UUID5('245b55a8-397d-4480-a41e-16603c8cf9ad')}", attributes, "74f6dc12-6d84-500c-9583-e9fed79912ea");
        verifyEquals("${myattr1:UUID5('45089bfa-f5eb-40e3-bc02-4270ccb8ef34')}", attributes, "7b197702-0ed0-5494-9f61-417e26010308");
        verifyEquals("${myattr2:UUID5('49861367-c791-4d6d-987e-fe994b2ee4b7')}", attributes, "7b38b455-a0d6-53bd-a0fa-d0f3bf4e7399");
        verifyEquals("${myattr3:UUID5('1142b2d9-e434-4931-b1a5-6dbf363aa9cf')}", attributes, "cd13422e-b030-547b-807b-a868e9282eab");
        verifyEquals("${myattr4:UUID5('967190d3-b4ba-4ef3-a8e6-3b8bf2d3f1d8')}", attributes, "e4f1ef89-0d25-55cd-bc4b-1904813c3137");
        verifyEquals("${myattr5:UUID5('2942f01d-82df-40ee-b1fd-476542160b7c')}", attributes, "a0415b30-5ef9-5530-93a2-fd20f4262d68");
        verifyEquals("${myattr6:UUID5('3a47c04b-7cea-4c95-a379-018e64c701c5')}", attributes, "e1931aad-30e8-5283-8505-394f0d08b181");
        verifyEquals("${myattr7:UUID5('6f78ce33-4186-46c0-ae05-15f8c78024cf')}", attributes, "a5c80e26-88d6-5de9-9234-66a050f4d940");
        verifyEquals("${myattr8:UUID5('b85962a8-6614-49f4-8fdd-a984cf35144e')}", attributes, "6e33ce29-d3f0-59ee-a864-c05ec4d4300d");
        verifyEquals("${myattr9:UUID5('5b6da974-4eca-4c17-bbd2-3b59a1b40bee')}", attributes, "2e2e846c-1cbc-54b2-96f7-5a66d246126f");
        verifyEmpty("${myattr10:UUID5('4939d5dd-51c1-4d0e-badd-77fa7c7eebc1')}", attributes);
        verifyEmpty("${myattr11:UUID5('4939d5dd-51c1-4d0e-badd-77fa7c7eebc1')}", attributes);
        verifyEquals("${myattr9:UUID5(${myattr11})}", attributes, "0231a7bf-7bbe-5a0c-8bbd-9c7bc2e95071");

        // Make sure it works using the UUID() expression for the namespace
        verifyEquals("${myattr0:UUID3(${UUID()}):length()}", attributes, 36L);
        verifyEquals("${myattr0:UUID5(${UUID()}):length()}", attributes, 36L);
    }

    @Test
    void testIsJson() {
        final Map<String, String> attributes = new HashMap<>();
        attributes.put("jsonObj", "{\"name\":\"John\", \"age\":30, \"car\":null}");
        attributes.put("jsonObjMissingStartingBrace", "\"name\":\"John\", \"age\":30, \"car\":null}");
        attributes.put("jsonObjMissingEndingBrace", "{\"name\":\"John\", \"age\":30, \"car\":null");
        attributes.put("jsonArray", "[\"Ford\", \"BMW\", \"Fiat\"]");
        attributes.put("jsonArrayMissingStartingBracket", "\"Ford\", \"BMW\", \"Fiat\"]");
        attributes.put("jsonArrayMissingEndingBracket", "[\"Ford\", \"BMW\", \"Fiat\"");
        attributes.put("emptyQuotedString", "\"\"");
        attributes.put("quotedString", "\"someString\"");
        attributes.put("integer", "1234");
        attributes.put("decimal", "18.36");
        attributes.put("trueAttr", "true");
        attributes.put("falseAttr", "false");
        attributes.put("nullAttr", "null");

        verifyEquals("${jsonObj:isJson()}", attributes, true);
        verifyEquals("${jsonObjMissingStartingBrace:isJson()}", attributes, false);
        verifyEquals("${jsonObjMissingEndingBrace:isJson()}", attributes, false);
        verifyEquals("${jsonArray:isJson()}", attributes, true);
        verifyEquals("${jsonArrayMissingStartingBracket:isJson()}", attributes, false);
        verifyEquals("${jsonArrayMissingEndingBracket:isJson()}", attributes, false);
        verifyEquals("${someAttribute:isJson()}", attributes, false);
        verifyEquals("${emptyQuotedString:isJson()}", attributes, false);
        verifyEquals("${quotedString:isJson()}", attributes, false);
        verifyEquals("${integer:isJson()}", attributes, false);
        verifyEquals("${decimal:isJson()}", attributes, false);
        verifyEquals("${trueAttr:isJson()}", attributes, false);
        verifyEquals("${falseAttr:isJson()}", attributes, false);
        verifyEquals("${nullAttr:isJson()}", attributes, false);
    }

    @Test
    void testGetUri() {
        verifyEquals("${getUri('https', 'admin:admin', 'nifi.apache.org', '1234', '/path/data ', 'key=value &key2=value2', 'frag1')}",
                null, "https://admin:admin@nifi.apache.org:1234/path/data%20?key=value%20&key2=value2#frag1");
        verifyEquals("${getUri('https', null, 'nifi.apache.org', 8443, '/docs.html', null, null)}",
                null, "https://nifi.apache.org:8443/docs.html");
        verifyEquals("${getUri('http', null, 'nifi.apache.org', -1, '/docs.html', null, null)}",
                null, "http://nifi.apache.org/docs.html");

        assertInvalid("${getUri()}");
        assertInvalid("${getUri('http://nifi.apache.org:1234/path/data?key=value&key2=value2#frag1')}");
        assertInvalid("${getUri('https', 'admin:admin')}");
        assertInvalid("${getUri('mailto', 'dev@nifi.apache.org', '')}");
        assertInvalid("${getUri('http', 'nifi.apache.org', '/path/data', 'frag1')}");
        assertInvalid("${getUri('https', 'admin:admin@nifi.apache.org:1234', '/path/data ', 'key=value&key2=value2', 'frag1')}");

        AttributeExpressionLanguageException thrown = assertThrows(AttributeExpressionLanguageException.class,
                () -> verifyEquals("${getUri('https', 'admin:admin', 'nifi.apache.org', 'notANumber', '/path/data ', 'key=value&key2=value2', 'frag1')}", null, ""));
        Assertions.assertEquals("Could not evaluate 'getUri' function with argument 'notANumber' which is not a number", thrown.getMessage());
    }

    private void verifyEquals(final String expression, final Map<String, String> attributes, final Object expectedResult) {
        verifyEquals(expression,attributes, null, ParameterLookup.EMPTY, expectedResult);
    }

    private void verifyEquals(final String expression, final Map<String, String> attributes, final Map<String, String> stateValues, final Object expectedResult) {
        verifyEquals(expression, attributes, stateValues, ParameterLookup.EMPTY, expectedResult);
    }

    private void verifyEquals(final String expression, final Map<String, String> attributes, final Map<String, String> stateValues, final Map<String, String> parameters,
                              final Object expectedResult) {

        verifyEquals(expression, attributes, stateValues, new MapParameterLookup(parameters), expectedResult);
    }

    private void verifyEquals(final String expression, final Map<String, String> attributes, final Map<String, String> stateValues, final ParameterLookup parameterLookup,
                              final Object expectedResult) {
        Query.validateExpression(expression, false);
        assertEquals(String.valueOf(expectedResult), Query.evaluateExpressions(expression, attributes, null, stateValues, parameterLookup));

        final Query query = Query.compile(expression);
        final QueryResult<?> result = query.evaluate(new StandardEvaluationContext(attributes, stateValues, parameterLookup));

        if (expectedResult instanceof Long) {
            if (ResultType.NUMBER.equals(result.getResultType())) {
                final Number resultNumber = ((NumberQueryResult) result).getValue();
                assertTrue(resultNumber instanceof Long);
            } else {
                assertEquals(ResultType.WHOLE_NUMBER, result.getResultType());
            }
        } else if(expectedResult instanceof Double) {
            if (ResultType.NUMBER.equals(result.getResultType())) {
                final Number resultNumber = ((NumberQueryResult) result).getValue();
                assertTrue(resultNumber instanceof Double);
            } else {
                assertEquals(ResultType.DECIMAL, result.getResultType());
            }
        } else if (expectedResult instanceof Boolean) {
            assertEquals(ResultType.BOOLEAN, result.getResultType());
        } else {
            assertEquals(ResultType.STRING, result.getResultType());
        }

        assertEquals(expectedResult, result.getValue());
    }

    private void verifyEmpty(final String expression, final Map<String, String> attributes) {
        Query.validateExpression(expression, false);
        assertEquals("", Query.evaluateExpressions(expression, attributes, null));
    }

    private String getResourceAsString(String resourceName) throws IOException {
        try (final Reader reader = new InputStreamReader(new BufferedInputStream(getClass().getResourceAsStream(resourceName)))) {
            int n = 0;
            char[] buf = new char[1024];
            StringBuilder sb = new StringBuilder();
            while (n != -1) {
                try {
                    n = reader.read(buf, 0, buf.length);
                } catch (IOException e) {
                    throw new RuntimeException("failed to read resource", e);
                }
                if (n > 0) {
                    sb.append(buf, 0, n);
                }
            }
            return sb.toString();
        }
    }


    private static class MapParameterLookup implements ParameterLookup {
        private final Map<String, String> parameters;

        public MapParameterLookup(final Map<String, String> parameters) {
            this.parameters = parameters;
        }

        @Override
        public Optional<Parameter> getParameter(final String parameterName) {
            final String value = parameters.get(parameterName);
            if (value == null) {
                return Optional.empty();
            }

            return Optional.of(new Parameter(new ParameterDescriptor.Builder().name(parameterName).build(), value));
        }

        @Override
        public boolean isEmpty() {
            return parameters.isEmpty();
        }

        @Override
        public long getVersion() {
            return 0;
        }
    }
}
