From 1a85890fc3ee90219900f5c8fb1b92fca1321849 Mon Sep 17 00:00:00 2001 From: Isha Gupta Date: Sun, 3 May 2026 20:47:47 +0530 Subject: [PATCH] Add ARRAY_TO_CSV helper function Signed-off-by: Isha Gupta --- .../function/BuiltinFunctionName.java | 1 + .../CollectionUDF/ArrayToCsvFunctionImpl.java | 111 ++++++++++++++ .../function/PPLBuiltinOperators.java | 2 + .../expression/function/PPLFuncImpTable.java | 2 + .../ArrayToCsvFunctionImplTest.java | 135 ++++++++++++++++++ ppl/src/main/antlr/OpenSearchPPLLexer.g4 | 1 + ppl/src/main/antlr/OpenSearchPPLParser.g4 | 1 + 7 files changed, 253 insertions(+) create mode 100644 core/src/main/java/org/opensearch/sql/expression/function/CollectionUDF/ArrayToCsvFunctionImpl.java create mode 100644 core/src/test/java/org/opensearch/sql/expression/function/CollectionUDF/ArrayToCsvFunctionImplTest.java diff --git a/core/src/main/java/org/opensearch/sql/expression/function/BuiltinFunctionName.java b/core/src/main/java/org/opensearch/sql/expression/function/BuiltinFunctionName.java index 14f058a75d0..4dd267984bf 100644 --- a/core/src/main/java/org/opensearch/sql/expression/function/BuiltinFunctionName.java +++ b/core/src/main/java/org/opensearch/sql/expression/function/BuiltinFunctionName.java @@ -72,6 +72,7 @@ public enum BuiltinFunctionName { ARRAY_LENGTH(FunctionName.of("array_length")), ARRAY_SLICE(FunctionName.of("array_slice"), true), ARRAY_COMPACT(FunctionName.of("array_compact")), + ARRAY_TO_CSV(FunctionName.of("array_to_csv")), MAP_APPEND(FunctionName.of("map_append"), true), MAP_CONCAT(FunctionName.of("map_concat"), true), MAP_REMOVE(FunctionName.of("map_remove"), true), diff --git a/core/src/main/java/org/opensearch/sql/expression/function/CollectionUDF/ArrayToCsvFunctionImpl.java b/core/src/main/java/org/opensearch/sql/expression/function/CollectionUDF/ArrayToCsvFunctionImpl.java new file mode 100644 index 00000000000..bad3a9dd808 --- /dev/null +++ b/core/src/main/java/org/opensearch/sql/expression/function/CollectionUDF/ArrayToCsvFunctionImpl.java @@ -0,0 +1,111 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.sql.expression.function.CollectionUDF; + +import java.util.List; +import org.apache.calcite.adapter.enumerable.NotNullImplementor; +import org.apache.calcite.adapter.enumerable.NullPolicy; +import org.apache.calcite.adapter.enumerable.RexToLixTranslator; +import org.apache.calcite.linq4j.tree.Expression; +import org.apache.calcite.linq4j.tree.Expressions; +import org.apache.calcite.linq4j.tree.Types; +import org.apache.calcite.rel.type.RelDataTypeFactory; +import org.apache.calcite.rex.RexCall; +import org.apache.calcite.sql.type.CompositeOperandTypeChecker; +import org.apache.calcite.sql.type.OperandTypes; +import org.apache.calcite.sql.type.SqlReturnTypeInference; +import org.apache.calcite.sql.type.SqlTypeFamily; +import org.apache.calcite.sql.type.SqlTypeName; +import org.opensearch.sql.expression.function.ImplementorUDF; +import org.opensearch.sql.expression.function.UDFOperandMetadata; + +/** + * ARRAY_TO_CSV function implementation that converts an array to a CSV string. + */ +public class ArrayToCsvFunctionImpl extends ImplementorUDF { + + public ArrayToCsvFunctionImpl() { + super(new ArrayToCsvImplementor(), NullPolicy.ARG0); + } + + @Override + public SqlReturnTypeInference getReturnTypeInference() { + return sqlOperatorBinding -> { + RelDataTypeFactory typeFactory = sqlOperatorBinding.getTypeFactory(); + return typeFactory.createTypeWithNullability( + typeFactory.createSqlType(SqlTypeName.VARCHAR), true); + }; + } + + @Override + public UDFOperandMetadata getOperandMetadata() { + // Accept ARRAY as first argument, optional STRING as second argument (delimiter) + return UDFOperandMetadata.wrap( + (CompositeOperandTypeChecker) + OperandTypes.family(SqlTypeFamily.ARRAY) + .or(OperandTypes.family(SqlTypeFamily.ARRAY, SqlTypeFamily.CHARACTER))); + } + + public static class ArrayToCsvImplementor implements NotNullImplementor { + @Override + public Expression implement( + RexToLixTranslator translator, RexCall call, List translatedOperands) { + // Handle both 1-argument (with default delimiter) and 2-argument cases + if (translatedOperands.size() == 1) { + // ARRAY_TO_CSV(array) - use default delimiter "," + return Expressions.call( + Types.lookupMethod( + ArrayToCsvFunctionImpl.class, "arrayToCsv", List.class, String.class), + translatedOperands.get(0), + Expressions.constant(",")); + } else if (translatedOperands.size() == 2) { + // ARRAY_TO_CSV(array, delimiter) + return Expressions.call( + Types.lookupMethod( + ArrayToCsvFunctionImpl.class, "arrayToCsv", List.class, String.class), + translatedOperands.get(0), + translatedOperands.get(1)); + } else { + throw new IllegalArgumentException( + "ARRAY_TO_CSV expects 1 or 2 arguments, got " + translatedOperands.size()); + } + } + } + + /** + * Converts an array to a CSV string. + * + * @param array The array to convert + * @param delimiter The delimiter to use for joining values + * @return CSV string representation of the array + */ + public static String arrayToCsv(List array, String delimiter) { + if (array == null) { + return null; + } + + if (delimiter == null) { + delimiter = ","; + } + + if (array.isEmpty()) { + return ""; + } + + StringBuilder result = new StringBuilder(); + for (int i = 0; i < array.size(); i++) { + if (i > 0) { + result.append(delimiter); + } + Object element = array.get(i); + if (element != null) { + result.append(element.toString()); + } + } + + return result.toString(); + } +} \ No newline at end of file diff --git a/core/src/main/java/org/opensearch/sql/expression/function/PPLBuiltinOperators.java b/core/src/main/java/org/opensearch/sql/expression/function/PPLBuiltinOperators.java index 0a5b0fe0e03..8bbca5199f1 100644 --- a/core/src/main/java/org/opensearch/sql/expression/function/PPLBuiltinOperators.java +++ b/core/src/main/java/org/opensearch/sql/expression/function/PPLBuiltinOperators.java @@ -43,6 +43,7 @@ import org.opensearch.sql.data.type.ExprCoreType; import org.opensearch.sql.expression.datetime.DateTimeFunctions; import org.opensearch.sql.expression.function.CollectionUDF.ArrayFunctionImpl; +import org.opensearch.sql.expression.function.CollectionUDF.ArrayToCsvFunctionImpl; import org.opensearch.sql.expression.function.CollectionUDF.ExistsFunctionImpl; import org.opensearch.sql.expression.function.CollectionUDF.FilterFunctionImpl; import org.opensearch.sql.expression.function.CollectionUDF.ForallFunctionImpl; @@ -400,6 +401,7 @@ public class PPLBuiltinOperators extends ReflectiveSqlOperatorTable { public static final SqlOperator FORALL = new ForallFunctionImpl().toUDF("forall"); public static final SqlOperator EXISTS = new ExistsFunctionImpl().toUDF("exists"); public static final SqlOperator ARRAY = new ArrayFunctionImpl().toUDF("array"); + public static final SqlOperator ARRAY_TO_CSV = new ArrayToCsvFunctionImpl().toUDF("array_to_csv"); public static final SqlOperator MAP_APPEND = new MapAppendFunctionImpl().toUDF("map_append"); public static final SqlOperator MAP_REMOVE = new MapRemoveFunctionImpl().toUDF("MAP_REMOVE"); public static final SqlOperator MVAPPEND = new MVAppendFunctionImpl().toUDF("mvappend"); diff --git a/core/src/main/java/org/opensearch/sql/expression/function/PPLFuncImpTable.java b/core/src/main/java/org/opensearch/sql/expression/function/PPLFuncImpTable.java index 849c60fe4eb..2ecb5c486ed 100644 --- a/core/src/main/java/org/opensearch/sql/expression/function/PPLFuncImpTable.java +++ b/core/src/main/java/org/opensearch/sql/expression/function/PPLFuncImpTable.java @@ -19,6 +19,7 @@ import static org.opensearch.sql.expression.function.BuiltinFunctionName.ARRAY_COMPACT; import static org.opensearch.sql.expression.function.BuiltinFunctionName.ARRAY_LENGTH; import static org.opensearch.sql.expression.function.BuiltinFunctionName.ARRAY_SLICE; +import static org.opensearch.sql.expression.function.BuiltinFunctionName.ARRAY_TO_CSV; import static org.opensearch.sql.expression.function.BuiltinFunctionName.ASCII; import static org.opensearch.sql.expression.function.BuiltinFunctionName.ASIN; import static org.opensearch.sql.expression.function.BuiltinFunctionName.ATAN; @@ -1062,6 +1063,7 @@ void populate() { registerOperator(ARRAY_LENGTH, SqlLibraryOperators.ARRAY_LENGTH); registerOperator(ARRAY_SLICE, SqlLibraryOperators.ARRAY_SLICE); registerOperator(ARRAY_COMPACT, SqlLibraryOperators.ARRAY_COMPACT); + registerOperator(ARRAY_TO_CSV, PPLBuiltinOperators.ARRAY_TO_CSV); registerOperator(FORALL, PPLBuiltinOperators.FORALL); registerOperator(EXISTS, PPLBuiltinOperators.EXISTS); registerOperator(FILTER, PPLBuiltinOperators.FILTER); diff --git a/core/src/test/java/org/opensearch/sql/expression/function/CollectionUDF/ArrayToCsvFunctionImplTest.java b/core/src/test/java/org/opensearch/sql/expression/function/CollectionUDF/ArrayToCsvFunctionImplTest.java new file mode 100644 index 00000000000..9cfc87daaae --- /dev/null +++ b/core/src/test/java/org/opensearch/sql/expression/function/CollectionUDF/ArrayToCsvFunctionImplTest.java @@ -0,0 +1,135 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.sql.expression.function.CollectionUDF; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import org.junit.jupiter.api.Test; + +class ArrayToCsvFunctionImplTest { + + @Test + void testArrayToCsvWithDefaultDelimiter() { + List array = Arrays.asList("GET", "READ", "WRITE"); + String result = ArrayToCsvFunctionImpl.arrayToCsv(array, ","); + assertEquals("GET,READ,WRITE", result); + } + + @Test + void testArrayToCsvWithCustomDelimiter() { + List array = Arrays.asList("GET", "READ", "WRITE"); + String result = ArrayToCsvFunctionImpl.arrayToCsv(array, ", "); + assertEquals("GET, READ, WRITE", result); + } + + @Test + void testArrayToCsvWithPipeDelimiter() { + List array = Arrays.asList("GET", "READ", "WRITE"); + String result = ArrayToCsvFunctionImpl.arrayToCsv(array, " | "); + assertEquals("GET | READ | WRITE", result); + } + + @Test + void testArrayToCsvWithEmptyArray() { + List array = Collections.emptyList(); + String result = ArrayToCsvFunctionImpl.arrayToCsv(array, ","); + assertEquals("", result); + } + + @Test + void testArrayToCsvWithNullArray() { + String result = ArrayToCsvFunctionImpl.arrayToCsv(null, ","); + assertNull(result); + } + + @Test + void testArrayToCsvWithNullElements() { + List array = Arrays.asList("GET", null, "WRITE"); + String result = ArrayToCsvFunctionImpl.arrayToCsv(array, ","); + assertEquals("GET,,WRITE", result); + } + + @Test + void testArrayToCsvWithSingleElement() { + List array = Arrays.asList("GET"); + String result = ArrayToCsvFunctionImpl.arrayToCsv(array, ","); + assertEquals("GET", result); + } + + @Test + void testArrayToCsvWithNumbers() { + List array = Arrays.asList(1, 2, 3); + String result = ArrayToCsvFunctionImpl.arrayToCsv(array, ","); + assertEquals("1,2,3", result); + } + + @Test + void testArrayToCsvWithMixedTypes() { + List array = Arrays.asList("GET", 123, true); + String result = ArrayToCsvFunctionImpl.arrayToCsv(array, ","); + assertEquals("GET,123,true", result); + } + + @Test + void testArrayToCsvWithNullDelimiter() { + List array = Arrays.asList("a", "b", "c"); + String result = ArrayToCsvFunctionImpl.arrayToCsv(array, null); + assertEquals("a,b,c", result); + } + + @Test + void testArrayToCsvWithNullDelimiterAndSingleElement() { + List array = Arrays.asList("GET"); + String result = ArrayToCsvFunctionImpl.arrayToCsv(array, null); + assertEquals("GET", result); + } + + @Test + void testArrayToCsvWithNullDelimiterAndEmptyArray() { + List array = Collections.emptyList(); + String result = ArrayToCsvFunctionImpl.arrayToCsv(array, null); + assertEquals("", result); + } + + @Test + void testArrayToCsvWithMultipleNullElements() { + List array = Arrays.asList("a", null, null, "b"); + String result = ArrayToCsvFunctionImpl.arrayToCsv(array, ","); + assertEquals("a,,,b", result); + } + + @Test + void testArrayToCsvWithLeadingNullElement() { + List array = Arrays.asList(null, "a", "b"); + String result = ArrayToCsvFunctionImpl.arrayToCsv(array, ","); + assertEquals(",a,b", result); + } + + @Test + void testArrayToCsvWithTrailingNullElement() { + List array = Arrays.asList("a", "b", null); + String result = ArrayToCsvFunctionImpl.arrayToCsv(array, ","); + assertEquals("a,b,", result); + } + + @Test + void testArrayToCsvWithAllNullElements() { + List array = Arrays.asList(null, null, null); + String result = ArrayToCsvFunctionImpl.arrayToCsv(array, ","); + assertEquals(",,", result); + } + + @Test + void testArrayToCsvWithNullElementsAndCustomDelimiter() { + List array = Arrays.asList("GET", null, "WRITE"); + String result = ArrayToCsvFunctionImpl.arrayToCsv(array, " | "); + assertEquals("GET | | WRITE", result); + } +} \ No newline at end of file diff --git a/ppl/src/main/antlr/OpenSearchPPLLexer.g4 b/ppl/src/main/antlr/OpenSearchPPLLexer.g4 index 4bc69a8f295..b42729e869f 100644 --- a/ppl/src/main/antlr/OpenSearchPPLLexer.g4 +++ b/ppl/src/main/antlr/OpenSearchPPLLexer.g4 @@ -480,6 +480,7 @@ ISBLANK: 'ISBLANK'; // COLLECTION FUNCTIONS ARRAY: 'ARRAY'; ARRAY_LENGTH: 'ARRAY_LENGTH'; +ARRAY_TO_CSV: 'ARRAY_TO_CSV'; MVAPPEND: 'MVAPPEND'; MVJOIN: 'MVJOIN'; MVINDEX: 'MVINDEX'; diff --git a/ppl/src/main/antlr/OpenSearchPPLParser.g4 b/ppl/src/main/antlr/OpenSearchPPLParser.g4 index bcaaa105774..628105d6d54 100644 --- a/ppl/src/main/antlr/OpenSearchPPLParser.g4 +++ b/ppl/src/main/antlr/OpenSearchPPLParser.g4 @@ -1231,6 +1231,7 @@ geoipFunctionName collectionFunctionName : ARRAY | ARRAY_LENGTH + | ARRAY_TO_CSV | MVAPPEND | MVJOIN | MVINDEX