diff --git a/data-prepper-expression/src/main/java/org/opensearch/dataprepper/expression/AbstractSubstringExpressionFunction.java b/data-prepper-expression/src/main/java/org/opensearch/dataprepper/expression/AbstractSubstringExpressionFunction.java new file mode 100644 index 0000000000..35c06cceb1 --- /dev/null +++ b/data-prepper-expression/src/main/java/org/opensearch/dataprepper/expression/AbstractSubstringExpressionFunction.java @@ -0,0 +1,59 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.dataprepper.expression; + +import org.opensearch.dataprepper.model.event.Event; +import org.opensearch.dataprepper.model.event.EventKey; + +import java.util.List; +import java.util.function.Function; + +abstract class AbstractSubstringExpressionFunction implements ExpressionFunction { + private static final int NUMBER_OF_ARGS = 2; + + @Override + public Object evaluate(final List args, final Event event, final Function convertLiteralType) { + if (args.size() != NUMBER_OF_ARGS) { + throw new RuntimeException(getFunctionName() + "() takes exactly two arguments"); + } + + final String[] strArgs = new String[NUMBER_OF_ARGS]; + for (int i = 0; i < NUMBER_OF_ARGS; i++) { + final Object arg = args.get(i); + if (arg instanceof EventKey) { + final Object obj = event.get((EventKey) arg, Object.class); + if (obj == null) { + strArgs[i] = null; + } else if (!(obj instanceof String)) { + throw new RuntimeException(String.format("%s() takes only string type arguments. \"%s\" is not of type string", getFunctionName(), obj)); + } else { + strArgs[i] = (String) obj; + } + } else if (arg instanceof String) { + strArgs[i] = (String) arg; + } else { + throw new RuntimeException("Unexpected argument type: " + arg.getClass()); + } + } + + final String source = strArgs[0]; + final String delimiter = strArgs[1]; + + if (source == null) { + return null; + } + if (delimiter == null || delimiter.isEmpty()) { + return source; + } + return extractSubstring(source, delimiter); + } + + protected abstract String extractSubstring(final String source, final String delimiter); +} diff --git a/data-prepper-expression/src/main/java/org/opensearch/dataprepper/expression/SubstringAfterExpressionFunction.java b/data-prepper-expression/src/main/java/org/opensearch/dataprepper/expression/SubstringAfterExpressionFunction.java new file mode 100644 index 0000000000..940033574f --- /dev/null +++ b/data-prepper-expression/src/main/java/org/opensearch/dataprepper/expression/SubstringAfterExpressionFunction.java @@ -0,0 +1,31 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.dataprepper.expression; + +import javax.inject.Named; + +@Named +public class SubstringAfterExpressionFunction extends AbstractSubstringExpressionFunction { + static final String FUNCTION_NAME = "substringAfter"; + + @Override + public String getFunctionName() { + return FUNCTION_NAME; + } + + @Override + protected String extractSubstring(final String source, final String delimiter) { + final int index = source.indexOf(delimiter); + if (index == -1) { + return source; + } + return source.substring(index + delimiter.length()); + } +} diff --git a/data-prepper-expression/src/main/java/org/opensearch/dataprepper/expression/SubstringAfterLastExpressionFunction.java b/data-prepper-expression/src/main/java/org/opensearch/dataprepper/expression/SubstringAfterLastExpressionFunction.java new file mode 100644 index 0000000000..9ddeefdbc3 --- /dev/null +++ b/data-prepper-expression/src/main/java/org/opensearch/dataprepper/expression/SubstringAfterLastExpressionFunction.java @@ -0,0 +1,31 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.dataprepper.expression; + +import javax.inject.Named; + +@Named +public class SubstringAfterLastExpressionFunction extends AbstractSubstringExpressionFunction { + static final String FUNCTION_NAME = "substringAfterLast"; + + @Override + public String getFunctionName() { + return FUNCTION_NAME; + } + + @Override + protected String extractSubstring(final String source, final String delimiter) { + final int index = source.lastIndexOf(delimiter); + if (index == -1) { + return source; + } + return source.substring(index + delimiter.length()); + } +} diff --git a/data-prepper-expression/src/main/java/org/opensearch/dataprepper/expression/SubstringBeforeExpressionFunction.java b/data-prepper-expression/src/main/java/org/opensearch/dataprepper/expression/SubstringBeforeExpressionFunction.java new file mode 100644 index 0000000000..c7c68d9617 --- /dev/null +++ b/data-prepper-expression/src/main/java/org/opensearch/dataprepper/expression/SubstringBeforeExpressionFunction.java @@ -0,0 +1,31 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.dataprepper.expression; + +import javax.inject.Named; + +@Named +public class SubstringBeforeExpressionFunction extends AbstractSubstringExpressionFunction { + static final String FUNCTION_NAME = "substringBefore"; + + @Override + public String getFunctionName() { + return FUNCTION_NAME; + } + + @Override + protected String extractSubstring(final String source, final String delimiter) { + final int index = source.indexOf(delimiter); + if (index == -1) { + return source; + } + return source.substring(0, index); + } +} diff --git a/data-prepper-expression/src/main/java/org/opensearch/dataprepper/expression/SubstringBeforeLastExpressionFunction.java b/data-prepper-expression/src/main/java/org/opensearch/dataprepper/expression/SubstringBeforeLastExpressionFunction.java new file mode 100644 index 0000000000..01ecf4335d --- /dev/null +++ b/data-prepper-expression/src/main/java/org/opensearch/dataprepper/expression/SubstringBeforeLastExpressionFunction.java @@ -0,0 +1,31 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.dataprepper.expression; + +import javax.inject.Named; + +@Named +public class SubstringBeforeLastExpressionFunction extends AbstractSubstringExpressionFunction { + static final String FUNCTION_NAME = "substringBeforeLast"; + + @Override + public String getFunctionName() { + return FUNCTION_NAME; + } + + @Override + protected String extractSubstring(final String source, final String delimiter) { + final int index = source.lastIndexOf(delimiter); + if (index == -1) { + return source; + } + return source.substring(0, index); + } +} diff --git a/data-prepper-expression/src/test/java/org/opensearch/dataprepper/expression/GenericExpressionEvaluator_ConditionalIT.java b/data-prepper-expression/src/test/java/org/opensearch/dataprepper/expression/GenericExpressionEvaluator_ConditionalIT.java index 8f9c7fafc9..face2bb552 100644 --- a/data-prepper-expression/src/test/java/org/opensearch/dataprepper/expression/GenericExpressionEvaluator_ConditionalIT.java +++ b/data-prepper-expression/src/test/java/org/opensearch/dataprepper/expression/GenericExpressionEvaluator_ConditionalIT.java @@ -250,7 +250,12 @@ private static Stream validExpressionArguments() { arguments("startsWith(\""+ UUID.randomUUID() +strValue+ "\",/status)", event("{\"status\":\""+strValue+"\"}"), false), arguments("getEventType() == \"event\"", longEvent, true), arguments("getEventType() == \"LOG\"", longEvent, false), - arguments("formatDateTime(/time, \"'year='yyyy'/month='MM'/day='dd\", \"UTC-8\") == \"year=2025/month=04/day=01\"", event("{\"time\": " + LocalDateTime.of(2025, 4, 1, 23, 59).toInstant(ZoneOffset.UTC).toEpochMilli() + "}"), true) + arguments("formatDateTime(/time, \"'year='yyyy'/month='MM'/day='dd\", \"UTC-8\") == \"year=2025/month=04/day=01\"", event("{\"time\": " + LocalDateTime.of(2025, 4, 1, 23, 59).toInstant(ZoneOffset.UTC).toEpochMilli() + "}"), true), + arguments("substringAfter(\"file.txt\", \".\") == \"txt\"", event("{}"), true), + arguments("substringAfter(/path, \"/\") == \"app/src/main.py\"", event("{\"path\": \"/app/src/main.py\"}"), true), + arguments("substringBefore(\"key=a=b\", \"=\") == \"key\"", event("{}"), true), + arguments("substringAfterLast(\"/app/src/main.py\", \"/\") == \"main.py\"", event("{}"), true), + arguments("substringBeforeLast(\"app.src.main\", \".\") == \"app.src\"", event("{}"), true) ); } @@ -297,7 +302,11 @@ private static Stream invalidExpressionArguments() { arguments("contains(1234, /strField)", event("{\"intField\":1234,\"strField\":\"string\"}")), arguments("contains(/strField, 1234)", event("{\"intField\":1234,\"strField\":\"string\"}")), arguments("getMetadata(10)", tagEvent), - arguments("cidrContains(/sourceIp,123)", event("{\"sourceIp\": \"192.0.2.3\"}")) + arguments("cidrContains(/sourceIp,123)", event("{\"sourceIp\": \"192.0.2.3\"}")), + arguments("substringAfter()", event("{}")), + arguments("substringBefore()", event("{}")), + arguments("substringAfterLast()", event("{}")), + arguments("substringBeforeLast()", event("{}")) ); } diff --git a/data-prepper-expression/src/test/java/org/opensearch/dataprepper/expression/GenericExpressionEvaluator_MultiTypeIT.java b/data-prepper-expression/src/test/java/org/opensearch/dataprepper/expression/GenericExpressionEvaluator_MultiTypeIT.java index e30013690e..cf3e74b984 100644 --- a/data-prepper-expression/src/test/java/org/opensearch/dataprepper/expression/GenericExpressionEvaluator_MultiTypeIT.java +++ b/data-prepper-expression/src/test/java/org/opensearch/dataprepper/expression/GenericExpressionEvaluator_MultiTypeIT.java @@ -132,7 +132,19 @@ private static Stream validStringExpressionArguments() { Arguments.of("getMetadata(\"strAttr\")+\""+testString2+"\"+/key", testEvent, testString+testString2+"value", String.class), Arguments.of("join(/list)", testEvent, "string,1,true", String.class), Arguments.of("join(\"\\\\, \", /list)", testEvent, "string, 1, true", String.class), - Arguments.of("join(\" \", /list)", testEvent, "string 1 true", String.class) + Arguments.of("join(\" \", /list)", testEvent, "string 1 true", String.class), + Arguments.of("substringAfter(\"hello-world\", \"-\")", event("{}"), "world", String.class), + Arguments.of("substringAfter(/field, \"-\")", event("{\"field\": \"hello-world\"}"), "world", String.class), + Arguments.of("substringAfter(\"no-match\", \"xyz\")", event("{}"), "no-match", String.class), + Arguments.of("substringBefore(\"hello-world\", \"-\")", event("{}"), "hello", String.class), + Arguments.of("substringBefore(/field, \"-\")", event("{\"field\": \"hello-world\"}"), "hello", String.class), + Arguments.of("substringBefore(\"no-match\", \"xyz\")", event("{}"), "no-match", String.class), + Arguments.of("substringAfterLast(\"/app/src/main.py\", \"/\")", event("{}"), "main.py", String.class), + Arguments.of("substringAfterLast(/field, \"/\")", event("{\"field\": \"/app/src/main.py\"}"), "main.py", String.class), + Arguments.of("substringAfterLast(\"no-match\", \"xyz\")", event("{}"), "no-match", String.class), + Arguments.of("substringBeforeLast(\"/app/src/main.py\", \"/\")", event("{}"), "/app/src", String.class), + Arguments.of("substringBeforeLast(/field, \"/\")", event("{\"field\": \"/app/src/main.py\"}"), "/app/src", String.class), + Arguments.of("substringBeforeLast(\"no-match\", \"xyz\")", event("{}"), "no-match", String.class) ); } @@ -155,7 +167,11 @@ private static Stream exceptionExpressionArguments() { Arguments.of("join(/list, \" \", \"third_arg\")", event("{\"list\":[\"string\", 1, true]}")), Arguments.of("join()", event("{\"list\":[\"string\", 1, true]}")), Arguments.of("contains()", event("{\"list\":[\"string\", 1, true]}")), - Arguments.of("startsWith()", event("{\"list\":[\"string\", 1, true]}")) + Arguments.of("startsWith()", event("{\"list\":[\"string\", 1, true]}")), + Arguments.of("substringAfter()", event("{\"list\":[\"string\", 1, true]}")), + Arguments.of("substringBefore()", event("{\"list\":[\"string\", 1, true]}")), + Arguments.of("substringAfterLast()", event("{\"list\":[\"string\", 1, true]}")), + Arguments.of("substringBeforeLast()", event("{\"list\":[\"string\", 1, true]}")) ); } diff --git a/data-prepper-expression/src/test/java/org/opensearch/dataprepper/expression/SubstringAfterExpressionFunctionTest.java b/data-prepper-expression/src/test/java/org/opensearch/dataprepper/expression/SubstringAfterExpressionFunctionTest.java new file mode 100644 index 0000000000..0a599e31f9 --- /dev/null +++ b/data-prepper-expression/src/test/java/org/opensearch/dataprepper/expression/SubstringAfterExpressionFunctionTest.java @@ -0,0 +1,153 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.dataprepper.expression; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.opensearch.dataprepper.event.TestEventKeyFactory; +import org.opensearch.dataprepper.model.event.Event; +import org.opensearch.dataprepper.model.event.EventKey; +import org.opensearch.dataprepper.model.event.EventKeyFactory; +import org.opensearch.dataprepper.model.event.JacksonEvent; + +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.function.Function; +import java.util.stream.Stream; + +import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.CoreMatchers.nullValue; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.opensearch.dataprepper.expression.SubstringAfterExpressionFunction.FUNCTION_NAME; + +class SubstringAfterExpressionFunctionTest { + + private final EventKeyFactory eventKeyFactory = TestEventKeyFactory.getTestEventFactory(); + private Event testEvent; + + private Event createTestEvent(final Object data) { + return JacksonEvent.builder().withEventType("event").withData(data).build(); + } + + private ExpressionFunction createObjectUnderTest() { + return new SubstringAfterExpressionFunction(); + } + + @ParameterizedTest + @MethodSource("validSubstringAfterProvider") + void substringAfter_returns_expected_result_when_evaluated( + final String value, final String delimiter, final String expectedResult) { + final String key = "test_key"; + testEvent = createTestEvent(Map.of(key, value)); + + final ExpressionFunction objectUnderTest = createObjectUnderTest(); + assertThat(objectUnderTest.getFunctionName(), equalTo(FUNCTION_NAME)); + + final EventKey eventKey = eventKeyFactory.createEventKey("/" + key); + final Object result = objectUnderTest.evaluate( + List.of(eventKey, delimiter), testEvent, mock(Function.class)); + + assertThat(result, equalTo(expectedResult)); + } + + @Test + void substringAfter_with_two_literals_returns_expected_result() { + final ExpressionFunction objectUnderTest = createObjectUnderTest(); + final Object result = objectUnderTest.evaluate( + List.of("key=a=b", "="), createTestEvent(Map.of()), mock(Function.class)); + assertThat(result, equalTo("a=b")); + } + + @Test + void substringAfter_with_a_key_as_the_delimiter_returns_expected_result() { + final String key = "test_key"; + final String value = "hello-world"; + testEvent = createTestEvent(Map.of(key, value, "delimiter", "-")); + + final ExpressionFunction objectUnderTest = createObjectUnderTest(); + final EventKey sourceKey = eventKeyFactory.createEventKey("/" + key); + final EventKey delimKey = eventKeyFactory.createEventKey("/delimiter"); + final Object result = objectUnderTest.evaluate( + List.of(sourceKey, delimKey), testEvent, mock(Function.class)); + assertThat(result, equalTo("world")); + } + + @Test + void substringAfter_returns_null_when_source_key_does_not_exist_in_Event() { + testEvent = createTestEvent(Map.of(UUID.randomUUID().toString(), UUID.randomUUID().toString())); + final ExpressionFunction objectUnderTest = createObjectUnderTest(); + final EventKey eventKey = eventKeyFactory.createEventKey("/test_key"); + final Object result = objectUnderTest.evaluate( + List.of(eventKey, "delim"), testEvent, mock(Function.class)); + assertThat(result, nullValue()); + } + + @Test + void substringAfter_returns_source_when_delimiter_key_does_not_exist_in_Event() { + final String key = "test_key"; + final String value = "hello"; + testEvent = createTestEvent(Map.of(key, value)); + final ExpressionFunction objectUnderTest = createObjectUnderTest(); + final EventKey sourceKey = eventKeyFactory.createEventKey("/" + key); + final EventKey delimKey = eventKeyFactory.createEventKey("/unknown"); + final Object result = objectUnderTest.evaluate( + List.of(sourceKey, delimKey), testEvent, mock(Function.class)); + assertThat(result, equalTo(value)); + } + + @Test + void substringAfter_returns_null_when_both_keys_do_not_exist_in_Event() { + testEvent = createTestEvent(Map.of(UUID.randomUUID().toString(), UUID.randomUUID().toString())); + final ExpressionFunction objectUnderTest = createObjectUnderTest(); + final EventKey key1 = eventKeyFactory.createEventKey("/unknown1"); + final EventKey key2 = eventKeyFactory.createEventKey("/unknown2"); + final Object result = objectUnderTest.evaluate( + List.of(key1, key2), testEvent, mock(Function.class)); + assertThat(result, nullValue()); + } + + @Test + void substringAfter_without_2_arguments_throws_RuntimeException() { + final ExpressionFunction objectUnderTest = createObjectUnderTest(); + assertThrows(RuntimeException.class, () -> objectUnderTest.evaluate( + List.of("abcd"), createTestEvent(Map.of()), mock(Function.class))); + } + + @Test + void substringAfter_with_eventKey_resolving_to_non_string_throws_RuntimeException() { + testEvent = createTestEvent(Map.of("test_key", 1234)); + final ExpressionFunction objectUnderTest = createObjectUnderTest(); + final EventKey eventKey = eventKeyFactory.createEventKey("/test_key"); + assertThrows(RuntimeException.class, () -> objectUnderTest.evaluate( + List.of(eventKey, "delim"), testEvent, mock(Function.class))); + } + + @Test + void substringAfter_with_unexpected_argument_type_throws_RuntimeException() { + final ExpressionFunction objectUnderTest = createObjectUnderTest(); + assertThrows(RuntimeException.class, () -> objectUnderTest.evaluate( + List.of("abcd", 1234), createTestEvent(Map.of()), mock(Function.class))); + } + + private static Stream validSubstringAfterProvider() { + return Stream.of( + Arguments.of("hello-world", "-", "world"), + Arguments.of("abc.def.ghi", ".", "def.ghi"), + Arguments.of("/app/src/main.py", "/", "app/src/main.py"), + Arguments.of("hello-world", "xyz", "hello-world"), + Arguments.of("hello-world", "", "hello-world") + ); + } +} diff --git a/data-prepper-expression/src/test/java/org/opensearch/dataprepper/expression/SubstringAfterLastExpressionFunctionTest.java b/data-prepper-expression/src/test/java/org/opensearch/dataprepper/expression/SubstringAfterLastExpressionFunctionTest.java new file mode 100644 index 0000000000..8c84f55098 --- /dev/null +++ b/data-prepper-expression/src/test/java/org/opensearch/dataprepper/expression/SubstringAfterLastExpressionFunctionTest.java @@ -0,0 +1,153 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.dataprepper.expression; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.opensearch.dataprepper.event.TestEventKeyFactory; +import org.opensearch.dataprepper.model.event.Event; +import org.opensearch.dataprepper.model.event.EventKey; +import org.opensearch.dataprepper.model.event.EventKeyFactory; +import org.opensearch.dataprepper.model.event.JacksonEvent; + +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.function.Function; +import java.util.stream.Stream; + +import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.CoreMatchers.nullValue; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.opensearch.dataprepper.expression.SubstringAfterLastExpressionFunction.FUNCTION_NAME; + +class SubstringAfterLastExpressionFunctionTest { + + private final EventKeyFactory eventKeyFactory = TestEventKeyFactory.getTestEventFactory(); + private Event testEvent; + + private Event createTestEvent(final Object data) { + return JacksonEvent.builder().withEventType("event").withData(data).build(); + } + + private ExpressionFunction createObjectUnderTest() { + return new SubstringAfterLastExpressionFunction(); + } + + @ParameterizedTest + @MethodSource("validSubstringAfterLastProvider") + void substringAfterLast_returns_expected_result_when_evaluated( + final String value, final String delimiter, final String expectedResult) { + final String key = "test_key"; + testEvent = createTestEvent(Map.of(key, value)); + + final ExpressionFunction objectUnderTest = createObjectUnderTest(); + assertThat(objectUnderTest.getFunctionName(), equalTo(FUNCTION_NAME)); + + final EventKey eventKey = eventKeyFactory.createEventKey("/" + key); + final Object result = objectUnderTest.evaluate( + List.of(eventKey, delimiter), testEvent, mock(Function.class)); + + assertThat(result, equalTo(expectedResult)); + } + + @Test + void substringAfterLast_with_two_literals_returns_expected_result() { + final ExpressionFunction objectUnderTest = createObjectUnderTest(); + final Object result = objectUnderTest.evaluate( + List.of("key=a=b", "="), createTestEvent(Map.of()), mock(Function.class)); + assertThat(result, equalTo("b")); + } + + @Test + void substringAfterLast_with_a_key_as_the_delimiter_returns_expected_result() { + final String key = "test_key"; + final String value = "/app/src/main.py"; + testEvent = createTestEvent(Map.of(key, value, "delimiter", "/")); + + final ExpressionFunction objectUnderTest = createObjectUnderTest(); + final EventKey sourceKey = eventKeyFactory.createEventKey("/" + key); + final EventKey delimKey = eventKeyFactory.createEventKey("/delimiter"); + final Object result = objectUnderTest.evaluate( + List.of(sourceKey, delimKey), testEvent, mock(Function.class)); + assertThat(result, equalTo("main.py")); + } + + @Test + void substringAfterLast_returns_null_when_source_key_does_not_exist_in_Event() { + testEvent = createTestEvent(Map.of(UUID.randomUUID().toString(), UUID.randomUUID().toString())); + final ExpressionFunction objectUnderTest = createObjectUnderTest(); + final EventKey eventKey = eventKeyFactory.createEventKey("/test_key"); + final Object result = objectUnderTest.evaluate( + List.of(eventKey, "delim"), testEvent, mock(Function.class)); + assertThat(result, nullValue()); + } + + @Test + void substringAfterLast_returns_source_when_delimiter_key_does_not_exist_in_Event() { + final String key = "test_key"; + final String value = "hello"; + testEvent = createTestEvent(Map.of(key, value)); + final ExpressionFunction objectUnderTest = createObjectUnderTest(); + final EventKey sourceKey = eventKeyFactory.createEventKey("/" + key); + final EventKey delimKey = eventKeyFactory.createEventKey("/unknown"); + final Object result = objectUnderTest.evaluate( + List.of(sourceKey, delimKey), testEvent, mock(Function.class)); + assertThat(result, equalTo(value)); + } + + @Test + void substringAfterLast_returns_null_when_both_keys_do_not_exist_in_Event() { + testEvent = createTestEvent(Map.of(UUID.randomUUID().toString(), UUID.randomUUID().toString())); + final ExpressionFunction objectUnderTest = createObjectUnderTest(); + final EventKey key1 = eventKeyFactory.createEventKey("/unknown1"); + final EventKey key2 = eventKeyFactory.createEventKey("/unknown2"); + final Object result = objectUnderTest.evaluate( + List.of(key1, key2), testEvent, mock(Function.class)); + assertThat(result, nullValue()); + } + + @Test + void substringAfterLast_without_2_arguments_throws_RuntimeException() { + final ExpressionFunction objectUnderTest = createObjectUnderTest(); + assertThrows(RuntimeException.class, () -> objectUnderTest.evaluate( + List.of("abcd"), createTestEvent(Map.of()), mock(Function.class))); + } + + @Test + void substringAfterLast_with_eventKey_resolving_to_non_string_throws_RuntimeException() { + testEvent = createTestEvent(Map.of("test_key", 1234)); + final ExpressionFunction objectUnderTest = createObjectUnderTest(); + final EventKey eventKey = eventKeyFactory.createEventKey("/test_key"); + assertThrows(RuntimeException.class, () -> objectUnderTest.evaluate( + List.of(eventKey, "delim"), testEvent, mock(Function.class))); + } + + @Test + void substringAfterLast_with_unexpected_argument_type_throws_RuntimeException() { + final ExpressionFunction objectUnderTest = createObjectUnderTest(); + assertThrows(RuntimeException.class, () -> objectUnderTest.evaluate( + List.of("abcd", 1234), createTestEvent(Map.of()), mock(Function.class))); + } + + private static Stream validSubstringAfterLastProvider() { + return Stream.of( + Arguments.of("/app/src/main.py", "/", "main.py"), + Arguments.of("abc.def.ghi", ".", "ghi"), + Arguments.of("hello-world", "-", "world"), + Arguments.of("hello-world", "xyz", "hello-world"), + Arguments.of("hello-world", "", "hello-world") + ); + } +} diff --git a/data-prepper-expression/src/test/java/org/opensearch/dataprepper/expression/SubstringBeforeExpressionFunctionTest.java b/data-prepper-expression/src/test/java/org/opensearch/dataprepper/expression/SubstringBeforeExpressionFunctionTest.java new file mode 100644 index 0000000000..d91ef518b1 --- /dev/null +++ b/data-prepper-expression/src/test/java/org/opensearch/dataprepper/expression/SubstringBeforeExpressionFunctionTest.java @@ -0,0 +1,153 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.dataprepper.expression; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.opensearch.dataprepper.event.TestEventKeyFactory; +import org.opensearch.dataprepper.model.event.Event; +import org.opensearch.dataprepper.model.event.EventKey; +import org.opensearch.dataprepper.model.event.EventKeyFactory; +import org.opensearch.dataprepper.model.event.JacksonEvent; + +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.function.Function; +import java.util.stream.Stream; + +import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.CoreMatchers.nullValue; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.opensearch.dataprepper.expression.SubstringBeforeExpressionFunction.FUNCTION_NAME; + +class SubstringBeforeExpressionFunctionTest { + + private final EventKeyFactory eventKeyFactory = TestEventKeyFactory.getTestEventFactory(); + private Event testEvent; + + private Event createTestEvent(final Object data) { + return JacksonEvent.builder().withEventType("event").withData(data).build(); + } + + private ExpressionFunction createObjectUnderTest() { + return new SubstringBeforeExpressionFunction(); + } + + @ParameterizedTest + @MethodSource("validSubstringBeforeProvider") + void substringBefore_returns_expected_result_when_evaluated( + final String value, final String delimiter, final String expectedResult) { + final String key = "test_key"; + testEvent = createTestEvent(Map.of(key, value)); + + final ExpressionFunction objectUnderTest = createObjectUnderTest(); + assertThat(objectUnderTest.getFunctionName(), equalTo(FUNCTION_NAME)); + + final EventKey eventKey = eventKeyFactory.createEventKey("/" + key); + final Object result = objectUnderTest.evaluate( + List.of(eventKey, delimiter), testEvent, mock(Function.class)); + + assertThat(result, equalTo(expectedResult)); + } + + @Test + void substringBefore_with_two_literals_returns_expected_result() { + final ExpressionFunction objectUnderTest = createObjectUnderTest(); + final Object result = objectUnderTest.evaluate( + List.of("key=a=b", "="), createTestEvent(Map.of()), mock(Function.class)); + assertThat(result, equalTo("key")); + } + + @Test + void substringBefore_with_a_key_as_the_delimiter_returns_expected_result() { + final String key = "test_key"; + final String value = "hello-world"; + testEvent = createTestEvent(Map.of(key, value, "delimiter", "-")); + + final ExpressionFunction objectUnderTest = createObjectUnderTest(); + final EventKey sourceKey = eventKeyFactory.createEventKey("/" + key); + final EventKey delimKey = eventKeyFactory.createEventKey("/delimiter"); + final Object result = objectUnderTest.evaluate( + List.of(sourceKey, delimKey), testEvent, mock(Function.class)); + assertThat(result, equalTo("hello")); + } + + @Test + void substringBefore_returns_null_when_source_key_does_not_exist_in_Event() { + testEvent = createTestEvent(Map.of(UUID.randomUUID().toString(), UUID.randomUUID().toString())); + final ExpressionFunction objectUnderTest = createObjectUnderTest(); + final EventKey eventKey = eventKeyFactory.createEventKey("/test_key"); + final Object result = objectUnderTest.evaluate( + List.of(eventKey, "delim"), testEvent, mock(Function.class)); + assertThat(result, nullValue()); + } + + @Test + void substringBefore_returns_source_when_delimiter_key_does_not_exist_in_Event() { + final String key = "test_key"; + final String value = "hello"; + testEvent = createTestEvent(Map.of(key, value)); + final ExpressionFunction objectUnderTest = createObjectUnderTest(); + final EventKey sourceKey = eventKeyFactory.createEventKey("/" + key); + final EventKey delimKey = eventKeyFactory.createEventKey("/unknown"); + final Object result = objectUnderTest.evaluate( + List.of(sourceKey, delimKey), testEvent, mock(Function.class)); + assertThat(result, equalTo(value)); + } + + @Test + void substringBefore_returns_null_when_both_keys_do_not_exist_in_Event() { + testEvent = createTestEvent(Map.of(UUID.randomUUID().toString(), UUID.randomUUID().toString())); + final ExpressionFunction objectUnderTest = createObjectUnderTest(); + final EventKey key1 = eventKeyFactory.createEventKey("/unknown1"); + final EventKey key2 = eventKeyFactory.createEventKey("/unknown2"); + final Object result = objectUnderTest.evaluate( + List.of(key1, key2), testEvent, mock(Function.class)); + assertThat(result, nullValue()); + } + + @Test + void substringBefore_without_2_arguments_throws_RuntimeException() { + final ExpressionFunction objectUnderTest = createObjectUnderTest(); + assertThrows(RuntimeException.class, () -> objectUnderTest.evaluate( + List.of("abcd"), createTestEvent(Map.of()), mock(Function.class))); + } + + @Test + void substringBefore_with_eventKey_resolving_to_non_string_throws_RuntimeException() { + testEvent = createTestEvent(Map.of("test_key", 1234)); + final ExpressionFunction objectUnderTest = createObjectUnderTest(); + final EventKey eventKey = eventKeyFactory.createEventKey("/test_key"); + assertThrows(RuntimeException.class, () -> objectUnderTest.evaluate( + List.of(eventKey, "delim"), testEvent, mock(Function.class))); + } + + @Test + void substringBefore_with_unexpected_argument_type_throws_RuntimeException() { + final ExpressionFunction objectUnderTest = createObjectUnderTest(); + assertThrows(RuntimeException.class, () -> objectUnderTest.evaluate( + List.of("abcd", 1234), createTestEvent(Map.of()), mock(Function.class))); + } + + private static Stream validSubstringBeforeProvider() { + return Stream.of( + Arguments.of("hello-world", "-", "hello"), + Arguments.of("abc.def.ghi", ".", "abc"), + Arguments.of("/app/src/main.py", "/", ""), + Arguments.of("hello-world", "xyz", "hello-world"), + Arguments.of("hello-world", "", "hello-world") + ); + } +} diff --git a/data-prepper-expression/src/test/java/org/opensearch/dataprepper/expression/SubstringBeforeLastExpressionFunctionTest.java b/data-prepper-expression/src/test/java/org/opensearch/dataprepper/expression/SubstringBeforeLastExpressionFunctionTest.java new file mode 100644 index 0000000000..a4802a47bb --- /dev/null +++ b/data-prepper-expression/src/test/java/org/opensearch/dataprepper/expression/SubstringBeforeLastExpressionFunctionTest.java @@ -0,0 +1,153 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.dataprepper.expression; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.opensearch.dataprepper.event.TestEventKeyFactory; +import org.opensearch.dataprepper.model.event.Event; +import org.opensearch.dataprepper.model.event.EventKey; +import org.opensearch.dataprepper.model.event.EventKeyFactory; +import org.opensearch.dataprepper.model.event.JacksonEvent; + +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.function.Function; +import java.util.stream.Stream; + +import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.CoreMatchers.nullValue; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.opensearch.dataprepper.expression.SubstringBeforeLastExpressionFunction.FUNCTION_NAME; + +class SubstringBeforeLastExpressionFunctionTest { + + private final EventKeyFactory eventKeyFactory = TestEventKeyFactory.getTestEventFactory(); + private Event testEvent; + + private Event createTestEvent(final Object data) { + return JacksonEvent.builder().withEventType("event").withData(data).build(); + } + + private ExpressionFunction createObjectUnderTest() { + return new SubstringBeforeLastExpressionFunction(); + } + + @ParameterizedTest + @MethodSource("validSubstringBeforeLastProvider") + void substringBeforeLast_returns_expected_result_when_evaluated( + final String value, final String delimiter, final String expectedResult) { + final String key = "test_key"; + testEvent = createTestEvent(Map.of(key, value)); + + final ExpressionFunction objectUnderTest = createObjectUnderTest(); + assertThat(objectUnderTest.getFunctionName(), equalTo(FUNCTION_NAME)); + + final EventKey eventKey = eventKeyFactory.createEventKey("/" + key); + final Object result = objectUnderTest.evaluate( + List.of(eventKey, delimiter), testEvent, mock(Function.class)); + + assertThat(result, equalTo(expectedResult)); + } + + @Test + void substringBeforeLast_with_two_literals_returns_expected_result() { + final ExpressionFunction objectUnderTest = createObjectUnderTest(); + final Object result = objectUnderTest.evaluate( + List.of("key=a=b", "="), createTestEvent(Map.of()), mock(Function.class)); + assertThat(result, equalTo("key=a")); + } + + @Test + void substringBeforeLast_with_a_key_as_the_delimiter_returns_expected_result() { + final String key = "test_key"; + final String value = "/app/src/main.py"; + testEvent = createTestEvent(Map.of(key, value, "delimiter", "/")); + + final ExpressionFunction objectUnderTest = createObjectUnderTest(); + final EventKey sourceKey = eventKeyFactory.createEventKey("/" + key); + final EventKey delimKey = eventKeyFactory.createEventKey("/delimiter"); + final Object result = objectUnderTest.evaluate( + List.of(sourceKey, delimKey), testEvent, mock(Function.class)); + assertThat(result, equalTo("/app/src")); + } + + @Test + void substringBeforeLast_returns_null_when_source_key_does_not_exist_in_Event() { + testEvent = createTestEvent(Map.of(UUID.randomUUID().toString(), UUID.randomUUID().toString())); + final ExpressionFunction objectUnderTest = createObjectUnderTest(); + final EventKey eventKey = eventKeyFactory.createEventKey("/test_key"); + final Object result = objectUnderTest.evaluate( + List.of(eventKey, "delim"), testEvent, mock(Function.class)); + assertThat(result, nullValue()); + } + + @Test + void substringBeforeLast_returns_source_when_delimiter_key_does_not_exist_in_Event() { + final String key = "test_key"; + final String value = "hello"; + testEvent = createTestEvent(Map.of(key, value)); + final ExpressionFunction objectUnderTest = createObjectUnderTest(); + final EventKey sourceKey = eventKeyFactory.createEventKey("/" + key); + final EventKey delimKey = eventKeyFactory.createEventKey("/unknown"); + final Object result = objectUnderTest.evaluate( + List.of(sourceKey, delimKey), testEvent, mock(Function.class)); + assertThat(result, equalTo(value)); + } + + @Test + void substringBeforeLast_returns_null_when_both_keys_do_not_exist_in_Event() { + testEvent = createTestEvent(Map.of(UUID.randomUUID().toString(), UUID.randomUUID().toString())); + final ExpressionFunction objectUnderTest = createObjectUnderTest(); + final EventKey key1 = eventKeyFactory.createEventKey("/unknown1"); + final EventKey key2 = eventKeyFactory.createEventKey("/unknown2"); + final Object result = objectUnderTest.evaluate( + List.of(key1, key2), testEvent, mock(Function.class)); + assertThat(result, nullValue()); + } + + @Test + void substringBeforeLast_without_2_arguments_throws_RuntimeException() { + final ExpressionFunction objectUnderTest = createObjectUnderTest(); + assertThrows(RuntimeException.class, () -> objectUnderTest.evaluate( + List.of("abcd"), createTestEvent(Map.of()), mock(Function.class))); + } + + @Test + void substringBeforeLast_with_eventKey_resolving_to_non_string_throws_RuntimeException() { + testEvent = createTestEvent(Map.of("test_key", 1234)); + final ExpressionFunction objectUnderTest = createObjectUnderTest(); + final EventKey eventKey = eventKeyFactory.createEventKey("/test_key"); + assertThrows(RuntimeException.class, () -> objectUnderTest.evaluate( + List.of(eventKey, "delim"), testEvent, mock(Function.class))); + } + + @Test + void substringBeforeLast_with_unexpected_argument_type_throws_RuntimeException() { + final ExpressionFunction objectUnderTest = createObjectUnderTest(); + assertThrows(RuntimeException.class, () -> objectUnderTest.evaluate( + List.of("abcd", 1234), createTestEvent(Map.of()), mock(Function.class))); + } + + private static Stream validSubstringBeforeLastProvider() { + return Stream.of( + Arguments.of("/app/src/main.py", "/", "/app/src"), + Arguments.of("abc.def.ghi", ".", "abc.def"), + Arguments.of("hello-world", "-", "hello"), + Arguments.of("hello-world", "xyz", "hello-world"), + Arguments.of("hello-world", "", "hello-world") + ); + } +} diff --git a/docs/expression_syntax.md b/docs/expression_syntax.md index 5485d40181..b71fb45b9a 100644 --- a/docs/expression_syntax.md +++ b/docs/expression_syntax.md @@ -200,6 +200,22 @@ Currently, the following functions are supported - If the IP address is in the range of any given CIDR blocks, the function evaluates to true; otherwise, the function evaluates to false. - The function supports both IPv4 and IPv6 addresses. For example, `cidrContains(/sourceIp,"192.0.2.0/24","10.0.1.0/16")` evaluates to true if the event has `sourceIp` field with value "192.0.2.5". + * `substringAfter()` + - takes two String arguments: a source and a delimiter. Both should be either string literals or Json Pointers with String values. + - returns the substring of the source that follows the first occurrence of the delimiter. If the delimiter is not found, the original source string is returned. If the source resolves to null, null is returned. + For example, `substringAfter("key=a=b", "=")` returns "a=b", and `substringAfter("hello-world", "xyz")` returns "hello-world". + * `substringBefore()` + - takes two String arguments: a source and a delimiter. Both should be either string literals or Json Pointers with String values. + - returns the substring of the source that precedes the first occurrence of the delimiter. If the delimiter is not found, the original source string is returned. If the source resolves to null, null is returned. + For example, `substringBefore("key=a=b", "=")` returns "key", and `substringBefore("hello-world", "xyz")` returns "hello-world". + * `substringAfterLast()` + - takes two String arguments: a source and a delimiter. Both should be either string literals or Json Pointers with String values. + - returns the substring of the source that follows the last occurrence of the delimiter. If the delimiter is not found, the original source string is returned. If the source resolves to null, null is returned. + For example, `substringAfterLast("/app/src/main.py", "/")` returns "main.py", and `substringAfterLast("hello-world", "xyz")` returns "hello-world". + * `substringBeforeLast()` + - takes two String arguments: a source and a delimiter. Both should be either string literals or Json Pointers with String values. + - returns the substring of the source that precedes the last occurrence of the delimiter. If the delimiter is not found, the original source string is returned. If the source resolves to null, null is returned. + For example, `substringBeforeLast("/app/src/main.py", "/")` returns "/app/src", and `substringBeforeLast("hello-world", "xyz")` returns "hello-world". ## White Space