diff --git a/client-v2/src/main/java/com/clickhouse/client/api/ClientConfigProperties.java b/client-v2/src/main/java/com/clickhouse/client/api/ClientConfigProperties.java index e548a90f9..6c7a8c27f 100644 --- a/client-v2/src/main/java/com/clickhouse/client/api/ClientConfigProperties.java +++ b/client-v2/src/main/java/com/clickhouse/client/api/ClientConfigProperties.java @@ -1,6 +1,7 @@ package com.clickhouse.client.api; import com.clickhouse.client.api.data_formats.internal.AbstractBinaryFormatReader; +import com.clickhouse.client.api.data_formats.internal.BinaryString; import com.clickhouse.client.api.internal.ClickHouseLZ4OutputStream; import com.clickhouse.data.ClickHouseDataType; import com.clickhouse.data.ClickHouseFormat; diff --git a/client-v2/src/main/java/com/clickhouse/client/api/data_formats/internal/AbstractBinaryFormatReader.java b/client-v2/src/main/java/com/clickhouse/client/api/data_formats/internal/AbstractBinaryFormatReader.java index e5892748d..8ebe3b544 100644 --- a/client-v2/src/main/java/com/clickhouse/client/api/data_formats/internal/AbstractBinaryFormatReader.java +++ b/client-v2/src/main/java/com/clickhouse/client/api/data_formats/internal/AbstractBinaryFormatReader.java @@ -32,6 +32,7 @@ import java.net.Inet4Address; import java.net.Inet6Address; import java.net.InetAddress; +import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.time.Instant; @@ -70,14 +71,19 @@ public abstract class AbstractBinaryFormatReader implements ClickHouseBinaryForm private TableSchema schema; private ClickHouseColumn[] columns; + private Class[] columnTypeHints; private Map[] convertions; + private Map> defaultTypeHintMap; private boolean hasNext = true; private boolean initialState = true; // reader is in initial state, no records have been read yet private long row = -1; // before first row private long lastNextCallTs; // for exception to detect slow reader - protected AbstractBinaryFormatReader(InputStream inputStream, QuerySettings querySettings, TableSchema schema,BinaryStreamReader.ByteBufferAllocator byteBufferAllocator, Map> defaultTypeHintMap) { + protected AbstractBinaryFormatReader(InputStream inputStream, QuerySettings querySettings, TableSchema schema, + BinaryStreamReader.ByteBufferAllocator byteBufferAllocator, + Map> defaultTypeHintMap) { this.input = inputStream; + this.defaultTypeHintMap = defaultTypeHintMap == null ? Collections.emptyMap() : defaultTypeHintMap; Map settings = querySettings == null ? Collections.emptyMap() : querySettings.getAllSettings(); Boolean useServerTimeZone = (Boolean) settings.get(ClientConfigProperties.USE_SERVER_TIMEZONE.getKey()); TimeZone timeZone = (useServerTimeZone == Boolean.TRUE && querySettings != null) ? @@ -89,7 +95,7 @@ protected AbstractBinaryFormatReader(InputStream inputStream, QuerySettings quer boolean jsonAsString = MapUtils.getFlag(settings, ClientConfigProperties.serverSetting(ServerSettings.OUTPUT_FORMAT_BINARY_WRITE_JSON_AS_STRING), false); this.binaryStreamReader = new BinaryStreamReader(inputStream, timeZone, LOG, byteBufferAllocator, jsonAsString, - defaultTypeHintMap); + defaultTypeHintMap, ByteBuffer::allocate); if (schema != null) { setSchema(schema); } @@ -188,7 +194,7 @@ protected boolean readRecord(Object[] record) throws IOException { boolean firstColumn = true; for (int i = 0; i < columns.length; i++) { try { - Object val = binaryStreamReader.readValue(columns[i]); + Object val = binaryStreamReader.readValue(columns[i], columnTypeHints[i]); if (val != null) { record[i] = val; } else { @@ -212,13 +218,18 @@ public T readValue(int colIndex) { if (colIndex < 1 || colIndex > getSchema().getColumns().size()) { throw new ClientException("Column index out of bounds: " + colIndex); } - return (T) currentRecord[colIndex - 1]; + + T value = (T) currentRecord[colIndex - 1]; + if (value instanceof BinaryString) { + return (T) ((BinaryString) value).asString(); + } + return value; } @SuppressWarnings("unchecked") @Override public T readValue(String colName) { - return (T) currentRecord[getSchema().nameToIndex(colName)]; + return readValue(getSchema().nameToColumnIndex(colName)); } @Override @@ -300,16 +311,21 @@ protected void setSchema(TableSchema schema) { this.schema = schema; this.columns = schema.getColumns().toArray(ClickHouseColumn.EMPTY_ARRAY); this.convertions = new Map[columns.length]; - + this.columnTypeHints = new Class[columns.length]; this.currentRecord = new Object[columns.length]; this.nextRecord = new Object[columns.length]; + Class stringTypeHint = defaultTypeHintMap.get(ClickHouseDataType.String); + for (int i = 0; i < columns.length; i++) { ClickHouseColumn column = columns[i]; ClickHouseDataType columnDataType = column.getDataType(); if (columnDataType.equals(ClickHouseDataType.SimpleAggregateFunction)){ columnDataType = column.getNestedColumns().get(0).getDataType(); } + if (columnDataType.equals(ClickHouseDataType.String)) { + columnTypeHints[i] = stringTypeHint; + } switch (columnDataType) { case Int8: case Int16: @@ -530,9 +546,11 @@ private T getPrimitiveArray(int index, Class componentType) { for (int i = 0; i < list.size(); i++) { Array.set(array, i, list.get(i)); } - return (T)array; + return (T) array; } else if (componentType == byte.class) { - if (value instanceof String) { + if (value instanceof BinaryString) { + return (T) ((BinaryString)value).asBytes(); + } else if(value instanceof String) { return (T) ((String) value).getBytes(StandardCharsets.UTF_8); } else if (value instanceof InetAddress) { return (T) ((InetAddress) value).getAddress(); diff --git a/client-v2/src/main/java/com/clickhouse/client/api/data_formats/internal/BinaryStreamReader.java b/client-v2/src/main/java/com/clickhouse/client/api/data_formats/internal/BinaryStreamReader.java index 829368e8f..68d0467d2 100644 --- a/client-v2/src/main/java/com/clickhouse/client/api/data_formats/internal/BinaryStreamReader.java +++ b/client-v2/src/main/java/com/clickhouse/client/api/data_formats/internal/BinaryStreamReader.java @@ -17,6 +17,8 @@ import java.math.BigInteger; import java.net.Inet4Address; import java.net.Inet6Address; +import java.nio.ByteBuffer; +import java.nio.CharBuffer; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.time.Instant; @@ -33,6 +35,7 @@ import java.util.Map; import java.util.TimeZone; import java.util.UUID; +import java.util.function.Function; /** * This class is not thread safe and should not be shared between multiple threads. @@ -51,6 +54,8 @@ public class BinaryStreamReader { private final ByteBufferAllocator bufferAllocator; + private final StringBufferAllocator stringBufferAllocator; + private final boolean jsonAsString; private final Class arrayDefaultTypeHint; @@ -69,11 +74,17 @@ public class BinaryStreamReader { * @param jsonAsString - use string to serialize/deserialize JSON columns * @param typeHintMapping - what type use as hint if hint is not set or may not be known. */ - BinaryStreamReader(InputStream input, TimeZone timeZone, Logger log, ByteBufferAllocator bufferAllocator, boolean jsonAsString, Map> typeHintMapping) { + BinaryStreamReader(InputStream input, TimeZone timeZone, Logger log, + ByteBufferAllocator bufferAllocator, + boolean jsonAsString, + Map> typeHintMapping, + StringBufferAllocator stringBufferAllocator) { this.log = log == null ? NOPLogger.NOP_LOGGER : log; this.timeZone = timeZone; this.input = input; this.bufferAllocator = bufferAllocator; + this.stringBufferAllocator = stringBufferAllocator; this.jsonAsString = jsonAsString; this.arrayDefaultTypeHint = typeHintMapping == null || @@ -121,13 +132,19 @@ public T readValue(ClickHouseColumn column, Class typeHint) throws IOExce switch (dataType) { // Primitives case FixedString: { - byte[] bytes = precision > STRING_BUFF.length ? - new byte[precision] : STRING_BUFF; - readNBytes(input, bytes, 0, precision); - return (T) new String(bytes, 0, precision, StandardCharsets.UTF_8); + if (typeHint == BinaryString.class) { + return (T) readBinaryString(precision, stringBufferAllocator::allocate); + } else { + return (T) readString(input, precision); + } } case String: { - return (T) readString(); + if (typeHint == BinaryString.class) { + int len = readVarInt(input); + return (T) readBinaryString(len, stringBufferAllocator::allocate); + } else { + return (T) readString(input); + } } case Int8: return (T) Byte.valueOf(readByte()); @@ -627,10 +644,10 @@ public ArrayValue readArrayItem(ClickHouseColumn itemTypeColumn, int len) throws if (itemTypeColumn.isNullable() || itemTypeColumn.getDataType() == ClickHouseDataType.Variant) { array = new ArrayValue(Object.class, len); for (int i = 0; i < len; i++) { - array.set(i, readValue(itemTypeColumn)); + array.set(i, readArrayItemValue(itemTypeColumn)); } } else { - Object firstValue = readValue(itemTypeColumn); + Object firstValue = readArrayItemValue(itemTypeColumn); Class itemClass = firstValue.getClass(); if (firstValue instanceof Byte) { itemClass = byte.class; @@ -657,12 +674,17 @@ public ArrayValue readArrayItem(ClickHouseColumn itemTypeColumn, int len) throws array = new ArrayValue(itemClass, len); array.set(0, firstValue); for (int i = 1; i < len; i++) { - array.set(i, readValue(itemTypeColumn)); + array.set(i, readArrayItemValue(itemTypeColumn)); } } return array; } + private Object readArrayItemValue(ClickHouseColumn itemTypeColumn) throws IOException { + Class typeHint = itemTypeColumn.getDataType() == ClickHouseDataType.String ? String.class : null; + return readValue(itemTypeColumn, typeHint); + } + public void skipValue(ClickHouseColumn column) throws IOException { readValue(column, null); } @@ -835,13 +857,18 @@ public String toString() { ClickHouseColumn valueType = column.getValueInfo(); LinkedHashMap map = new LinkedHashMap<>(len); for (int i = 0; i < len; i++) { - Object key = readValue(keyType); - Object value = readValue(valueType); + Object key = readMapKeyOrValue(keyType); + Object value = readMapKeyOrValue(valueType); map.put(key, value); } return map; } + private Object readMapKeyOrValue(ClickHouseColumn c) throws IOException { + Class typeHint = c.getDataType() == ClickHouseDataType.String ? String.class : null; + return readValue(c, typeHint); + } + /** * Reads a tuple. * @param column - column information @@ -1114,6 +1141,28 @@ public String readString() throws IOException { return new String(dest, 0, len, StandardCharsets.UTF_8); } + public BinaryString readBinaryString(int len, Function bufferAllocator) throws IOException { + ByteBuffer buffer = null; + if (len > 0) { + buffer = bufferAllocator.apply(len); + if (buffer == null) { + throw new IOException("bufferAllocator returned `null`"); + } + if (buffer.hasArray()) { + readNBytes(input, buffer.array(), 0, len); + } else { + int left = len; + while (left > 0) { + int chunkSize = Math.min(STRING_BUFF.length, left); + readNBytes(input, STRING_BUFF, 0, chunkSize); + buffer.put(STRING_BUFF, 0, chunkSize); + left -= chunkSize; + } + } + } + return buffer == null ? null : new BinaryStringImpl(buffer); + } + /** * Reads a decimal value from input stream. * @param input - source of bytes @@ -1122,6 +1171,10 @@ public String readString() throws IOException { */ public static String readString(InputStream input) throws IOException { int len = readVarInt(input); + return readString(input, len); + } + + public static String readString(InputStream input, int len) throws IOException { if (len == 0) { return ""; } @@ -1140,6 +1193,10 @@ public interface ByteBufferAllocator { byte[] allocate(int size); } + public interface StringBufferAllocator { + ByteBuffer allocate(int size); + } + /** * Byte allocator that creates a new byte array for each request. */ @@ -1394,4 +1451,56 @@ private Map readJsonData(InputStream input, ClickHouseColumn col } return obj; } + + static final class BinaryStringImpl implements BinaryString { + + private final ByteBuffer buffer; + private final int len; + private CharBuffer charBuffer = null; + private String strValue = null; + + BinaryStringImpl(ByteBuffer buffer) { + this.buffer = buffer; + this.len = buffer.limit(); + } + + @Override + public String asString() { + if (strValue == null) { + if (buffer.hasArray()) { + strValue = new String(buffer.array(), StandardCharsets.UTF_8); + } else { + ensureCharBuffer(); + strValue = charBuffer.toString(); + } + } + return strValue; + } + + @Override + public byte[] asBytes() { + if (buffer.hasArray()) { + return buffer.array(); + } + + throw new UnsupportedOperationException("String is stored out of the heap and has no byte buffer easily accessible"); + } + + @Override + public int length() { + return len; + } + + private void ensureCharBuffer() { + if (charBuffer == null) { + buffer.rewind(); + charBuffer = StandardCharsets.UTF_8.decode(buffer); + } + } + + @Override + public int compareTo(String o) { + return asString().compareTo(o); + } + } } diff --git a/client-v2/src/main/java/com/clickhouse/client/api/data_formats/internal/BinaryString.java b/client-v2/src/main/java/com/clickhouse/client/api/data_formats/internal/BinaryString.java new file mode 100644 index 000000000..ab8d58a71 --- /dev/null +++ b/client-v2/src/main/java/com/clickhouse/client/api/data_formats/internal/BinaryString.java @@ -0,0 +1,15 @@ +package com.clickhouse.client.api.data_formats.internal; + +public interface BinaryString extends Comparable { + + + int length(); + + /** + * Converts raw bytes to a string whenever size is. + * @return String object + */ + String asString(); + + byte[] asBytes(); +} diff --git a/client-v2/src/main/java/com/clickhouse/client/api/internal/DataTypeConverter.java b/client-v2/src/main/java/com/clickhouse/client/api/internal/DataTypeConverter.java index de4830e9f..ee00e2d60 100644 --- a/client-v2/src/main/java/com/clickhouse/client/api/internal/DataTypeConverter.java +++ b/client-v2/src/main/java/com/clickhouse/client/api/internal/DataTypeConverter.java @@ -3,6 +3,8 @@ import com.clickhouse.client.api.ClickHouseException; import com.clickhouse.client.api.DataTypeUtils; import com.clickhouse.client.api.data_formats.internal.BinaryStreamReader; +import com.clickhouse.client.api.data_formats.internal.BinaryString; +import com.clickhouse.client.api.data_formats.internal.ValueConverters; import com.clickhouse.data.ClickHouseColumn; import com.clickhouse.data.ClickHouseDataType; @@ -36,6 +38,8 @@ public class DataTypeConverter { private final ArrayAsStringWriter arrayAsStringWriter = new ArrayAsStringWriter(); + private final ValueConverters valueConverters = new ValueConverters(); + public String convertToString(Object value, ClickHouseColumn column) { if (value == null) { return null; @@ -72,21 +76,29 @@ public String convertToString(Object value, ClickHouseColumn column) { } public String stringToString(Object bytesOrString, ClickHouseColumn column) { - StringBuilder sb = new StringBuilder(); if (column.isArray()) { + StringBuilder sb = new StringBuilder(); sb.append(QUOTE); - } - if (bytesOrString instanceof CharSequence) { - sb.append(((CharSequence) bytesOrString)); - } else if (bytesOrString instanceof byte[]) { - sb.append(new String((byte[]) bytesOrString)); - } else { - sb.append(bytesOrString); - } - if (column.isArray()) { + if (bytesOrString instanceof BinaryString) { + sb.append(((BinaryString)bytesOrString).asString()); // string will be cached + } else if (bytesOrString instanceof CharSequence) { + sb.append(((CharSequence) bytesOrString)); + } else if (bytesOrString instanceof byte[]) { + sb.append(new String((byte[]) bytesOrString)); + } else { + sb.append(bytesOrString); + } sb.append(QUOTE); + return sb.toString(); + } else { + if (bytesOrString instanceof BinaryString) { + return ((BinaryString)bytesOrString).asString(); // string will be cached + } else if (bytesOrString instanceof byte[]) { + return new String((byte[]) bytesOrString); + } else { + return bytesOrString.toString(); + } } - return sb.toString(); } public static ZoneId UTC_ZONE_ID = ZoneId.of("UTC"); diff --git a/client-v2/src/test/java/com/clickhouse/client/api/data_formats/internal/BinaryStreamReaderTests.java b/client-v2/src/test/java/com/clickhouse/client/api/data_formats/internal/BinaryStreamReaderTests.java index 0d94e0a5f..3540c913b 100644 --- a/client-v2/src/test/java/com/clickhouse/client/api/data_formats/internal/BinaryStreamReaderTests.java +++ b/client-v2/src/test/java/com/clickhouse/client/api/data_formats/internal/BinaryStreamReaderTests.java @@ -1,20 +1,20 @@ package com.clickhouse.client.api.data_formats.internal; -import com.clickhouse.data.ClickHouseColumn; +import org.testng.Assert; +import org.testng.annotations.BeforeClass; +import org.testng.annotations.DataProvider; +import org.testng.annotations.Test; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; import java.time.ZoneId; import java.time.ZonedDateTime; import java.time.temporal.ChronoUnit; import java.util.TimeZone; -import org.testng.Assert; -import org.testng.annotations.BeforeClass; -import org.testng.annotations.DataProvider; -import org.testng.annotations.Test; - public class BinaryStreamReaderTests { private ZoneId tzLAX; @@ -191,17 +191,36 @@ public void testArrayValue() throws Exception { Assert.assertEquals(array1.length, array2.length); } - @Test - public void testReadNullVariantReturnsNull() throws Exception { - ClickHouseColumn column = ClickHouseColumn.of("v", "Variant(Int32, String)"); - BinaryStreamReader reader = new BinaryStreamReader( - new ByteArrayInputStream(new byte[]{(byte) 0xFF}), - TimeZone.getTimeZone("UTC"), - null, - new BinaryStreamReader.CachingByteBufferAllocator(), - false, - null); - - Assert.assertNull(reader.readValue(column)); + @Test(dataProvider = "testBinaryStringDP") + public void testBinaryString(String type, String originalString, byte[] originalStrBytes, ByteBuffer buffer) throws Exception { + BinaryString binaryString = new BinaryStreamReader.BinaryStringImpl(buffer); + + String firstStringAttempt = binaryString.asString(); + Assert.assertEquals(firstStringAttempt, originalString); + // Binary caches string + Assert.assertSame(binaryString.asString(), firstStringAttempt); + + Assert.assertTrue(binaryString.compareTo(originalString) == 0); + + // String length is less because of unicode bytes + Assert.assertEquals(binaryString.length(), originalString.getBytes(StandardCharsets.UTF_8).length); + + if (type.equalsIgnoreCase("heap")) { + Assert.assertEquals(binaryString.asBytes(), firstStringAttempt.getBytes()); + } else { + Assert.assertThrows(UnsupportedOperationException.class, binaryString::asBytes); + } + } + + @DataProvider + public static Object[][] testBinaryStringDP() { + final String originalString = "This should be Hello in different languages: 'こんにちは', 'Hej', 'Γεια σας'"; + final byte[] originalStrBytes = originalString.getBytes(); + ByteBuffer directBuffer = ByteBuffer.allocateDirect(originalStrBytes.length); + directBuffer.put(originalStrBytes,0, originalStrBytes.length); + return new Object[][] { + {"heap", originalString, originalStrBytes, ByteBuffer.wrap(originalStrBytes)}, + {"direct", originalString, originalStrBytes, directBuffer}, + }; } } diff --git a/client-v2/src/test/java/com/clickhouse/client/datatypes/DataTypeTests.java b/client-v2/src/test/java/com/clickhouse/client/datatypes/DataTypeTests.java index e87021a5d..fc328fa13 100644 --- a/client-v2/src/test/java/com/clickhouse/client/datatypes/DataTypeTests.java +++ b/client-v2/src/test/java/com/clickhouse/client/datatypes/DataTypeTests.java @@ -8,6 +8,7 @@ import com.clickhouse.client.api.ClientException; import com.clickhouse.client.api.command.CommandSettings; import com.clickhouse.client.api.data_formats.ClickHouseBinaryFormatReader; +import com.clickhouse.client.api.data_formats.RowBinaryFormatReader; import com.clickhouse.client.api.data_formats.internal.BinaryStreamReader; import com.clickhouse.client.api.enums.Protocol; import com.clickhouse.client.api.insert.InsertSettings; @@ -32,6 +33,7 @@ import java.lang.reflect.Method; import java.math.BigDecimal; import java.math.RoundingMode; +import java.nio.charset.StandardCharsets; import java.sql.Connection; import java.time.Duration; import java.time.Instant; @@ -52,6 +54,7 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.BiConsumer; +import java.util.function.Consumer; public class DataTypeTests extends BaseIntegrationTest { @@ -1738,9 +1741,79 @@ public Object[][] testJSONSubPathAccess_dp() { }; } + @Test(groups = {"integration"}) + public void testReadingStrings() throws Exception { + int smallStrLen = 1_000_000; + int tinyStrLen = 100_000; + final String sql = "SELECT repeat('A', " + smallStrLen + ") as smallStr, repeat('B', " + tinyStrLen +") as tinyStr, NULL::Nullable(String) as nullStr FROM numbers(100)"; + try (QueryResponse response = client.query(sql).get(); + ClickHouseBinaryFormatReader reader = client.newBinaryFormatReader(response)) { + + + while (reader.next() != null) { + byte[] smallStrBytes = reader.getByteArray("smallStr"); + Assert.assertEquals(smallStrBytes.length, smallStrLen); + + String smallStrFromBytes = new String(smallStrBytes, StandardCharsets.UTF_8); + String smallStr = reader.readValue("smallStr"); + Assert.assertEquals(smallStr, smallStrFromBytes); + // We should not create new objects + Assert.assertSame(reader.readValue("smallStr"), smallStr); + Assert.assertSame(reader.getString("smallStr"), smallStr); + + + byte[] tinyStrBytes = reader.getByteArray("tinyStr"); + Assert.assertEquals(tinyStrBytes.length, tinyStrLen); + + String tinyStrFromBytes = new String(tinyStrBytes, StandardCharsets.UTF_8); + String tinyStr = reader.readValue("tinyStr"); + Assert.assertEquals(tinyStr, tinyStrFromBytes); + + // We should not create new objects + Assert.assertSame(reader.readValue("tinyStr"), tinyStr); + Assert.assertSame(reader.getString("tinyStr"), tinyStr); + + // check null values + Assert.assertNull(reader.getByteArray("nullStr")); + Assert.assertNull(reader.readValue("nullStr")); + Assert.assertNull(reader.getString("nullStr")); + } + } + } + + @Test(groups = {"integration"}) + public void testStringsInNestedTypes() throws Exception { + final String sqlArray = "SELECT ['a', 'b', 'c'] as strArr, [['item1', null, 'item3'], ['item1', 'item2']]::Array(Array(Nullable(String))) as arr"; + try (QueryResponse response = client.query(sqlArray).get(); + ClickHouseBinaryFormatReader reader = client.newBinaryFormatReader(response)) { + + while (reader.next() != null) { + + List strArr = reader.getList("strArr"); + Assert.assertEquals(strArr, Arrays.asList("a", "b", "c")); + List> arr = reader.getList("arr"); + Assert.assertEquals(arr, Arrays.asList(Arrays.asList("item1", null, "item3"), Arrays.asList("item1", "item2"))); + + } + } + + final String sqlMap = "SELECT map('k1', NULL, 'k2', 'test string') as map1"; + final Map expectedMap = new HashMap<>(); + expectedMap.put("k1", null); + expectedMap.put("k2", "test string"); + try (QueryResponse response = client.query(sqlMap).get(); + ClickHouseBinaryFormatReader reader = client.newBinaryFormatReader(response)) { + + while (reader.next() != null) { + Map map = reader.readValue("map1"); + Assert.assertEquals(map, expectedMap); + } + } + } + public static String tableDefinition(String table, String... columns) { StringBuilder sb = new StringBuilder(); - sb.append("CREATE TABLE " + table + " ( "); + sb.append("CREATE TABLE ").append(table).append(" ( "); Arrays.stream(columns).forEach(s -> { sb.append(s).append(", "); });