diff --git a/data-loader/core/build.gradle b/data-loader/core/build.gradle index 28a2dba4de..5239fd42ef 100644 --- a/data-loader/core/build.gradle +++ b/data-loader/core/build.gradle @@ -9,7 +9,7 @@ archivesBaseName = "scalardb-data-loader-core" dependencies { // ScalarDB core implementation project(':core') - + // for SpotBugs compileOnly "com.github.spotbugs:spotbugs-annotations:${spotbugsVersion}" testCompileOnly "com.github.spotbugs:spotbugs-annotations:${spotbugsVersion}" diff --git a/data-loader/core/src/main/java/com/scalar/db/dataloader/core/ColumnInfo.java b/data-loader/core/src/main/java/com/scalar/db/dataloader/core/ColumnInfo.java new file mode 100644 index 0000000000..685f58a833 --- /dev/null +++ b/data-loader/core/src/main/java/com/scalar/db/dataloader/core/ColumnInfo.java @@ -0,0 +1,24 @@ +package com.scalar.db.dataloader.core; + +import lombok.Builder; +import lombok.Value; + +/** + * Represents a column in a database table. + * + *

This class holds the metadata for a column, including the namespace (schema), table name, and + * the column name within the table. + */ +@Value +@Builder +public class ColumnInfo { + + /** The namespace (schema) where the table is located. */ + String namespace; + + /** The name of the table where the column resides. */ + String tableName; + + /** The name of the column in the table. */ + String columnName; +} diff --git a/data-loader/core/src/main/java/com/scalar/db/dataloader/core/ErrorMessage.java b/data-loader/core/src/main/java/com/scalar/db/dataloader/core/ErrorMessage.java new file mode 100644 index 0000000000..1b1986288b --- /dev/null +++ b/data-loader/core/src/main/java/com/scalar/db/dataloader/core/ErrorMessage.java @@ -0,0 +1,10 @@ +package com.scalar.db.dataloader.core; + +public class ErrorMessage { + public static final String INVALID_NUMBER_FORMAT_FOR_COLUMN_VALUE = + "Invalid number specified for column %s in table %s in namespace %s"; + public static final String INVALID_BASE64_ENCODING_FOR_COLUMN_VALUE = + "Invalid base64 encoding for blob value for column %s in table %s in namespace %s"; + public static final String INVALID_COLUMN_NON_EXISTENT = + "Invalid key: Column %s does not exist in the table %s in namespace %s."; +} diff --git a/data-loader/core/src/main/java/com/scalar/db/dataloader/core/exception/ColumnParsingException.java b/data-loader/core/src/main/java/com/scalar/db/dataloader/core/exception/ColumnParsingException.java new file mode 100644 index 0000000000..58880d70bb --- /dev/null +++ b/data-loader/core/src/main/java/com/scalar/db/dataloader/core/exception/ColumnParsingException.java @@ -0,0 +1,30 @@ +package com.scalar.db.dataloader.core.exception; + +/** + * An exception that is thrown when an error occurs while trying to create a ScalarDB column from a + * value. + * + *

This exception is typically used to indicate a problem with parsing or converting data into a + * format that can be used to create a column in ScalarDB. + */ +public class ColumnParsingException extends Exception { + + /** + * Constructs a new {@code ColumnParsingException} with the specified detail message. + * + * @param message the detail message explaining the cause of the exception + */ + public ColumnParsingException(String message) { + super(message); + } + + /** + * Constructs a new {@code ColumnParsingException} with the specified detail message and cause. + * + * @param message the detail message explaining the cause of the exception + * @param cause the cause of the exception (can be {@code null}) + */ + public ColumnParsingException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/data-loader/core/src/main/java/com/scalar/db/dataloader/core/exception/KeyParsingException.java b/data-loader/core/src/main/java/com/scalar/db/dataloader/core/exception/KeyParsingException.java new file mode 100644 index 0000000000..b0efa86345 --- /dev/null +++ b/data-loader/core/src/main/java/com/scalar/db/dataloader/core/exception/KeyParsingException.java @@ -0,0 +1,30 @@ +package com.scalar.db.dataloader.core.exception; + +/** + * An exception that is thrown when an error occurs while trying to create a ScalarDB key from a + * value. + * + *

This exception is typically used to indicate a problem with parsing or converting data into a + * format that can be used to create a key in ScalarDB. + */ +public class KeyParsingException extends Exception { + + /** + * Constructs a new {@code KeyParsingException} with the specified detail message. + * + * @param message the detail message explaining the cause of the exception + */ + public KeyParsingException(String message) { + super(message); + } + + /** + * Constructs a new {@code KeyParsingException} with the specified detail message and cause. + * + * @param message the detail message explaining the cause of the exception + * @param cause the cause of the exception (can be {@code null}) + */ + public KeyParsingException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/data-loader/core/src/main/java/com/scalar/db/dataloader/core/util/ColumnUtils.java b/data-loader/core/src/main/java/com/scalar/db/dataloader/core/util/ColumnUtils.java new file mode 100644 index 0000000000..1a846d40c2 --- /dev/null +++ b/data-loader/core/src/main/java/com/scalar/db/dataloader/core/util/ColumnUtils.java @@ -0,0 +1,99 @@ +package com.scalar.db.dataloader.core.util; + +import static com.scalar.db.dataloader.core.ErrorMessage.INVALID_BASE64_ENCODING_FOR_COLUMN_VALUE; +import static com.scalar.db.dataloader.core.ErrorMessage.INVALID_NUMBER_FORMAT_FOR_COLUMN_VALUE; + +import com.scalar.db.dataloader.core.ColumnInfo; +import com.scalar.db.dataloader.core.exception.ColumnParsingException; +import com.scalar.db.io.BigIntColumn; +import com.scalar.db.io.BlobColumn; +import com.scalar.db.io.BooleanColumn; +import com.scalar.db.io.Column; +import com.scalar.db.io.DataType; +import com.scalar.db.io.DoubleColumn; +import com.scalar.db.io.FloatColumn; +import com.scalar.db.io.IntColumn; +import com.scalar.db.io.TextColumn; +import java.util.Base64; +import javax.annotation.Nullable; + +/** + * Utility class for creating and managing ScalarDB columns. + * + *

This class provides methods for creating ScalarDB columns based on the given data type, column + * information, and value. It includes handling for various data types and special cases like base64 + * encoding for BLOB data. + */ +public final class ColumnUtils { + + /** Restrict instantiation via private constructor */ + private ColumnUtils() {} + + /** + * Creates a ScalarDB column from the given data type, column information, and value. + * + *

Blob source values need to be base64 encoded before passing them as a value. If the value is + * {@code null}, the corresponding column is created as a {@code null} column. + * + * @param dataType the data type of the specified column + * @param columnInfo the ScalarDB table column information + * @param value the value for the ScalarDB column (may be {@code null}) + * @return the ScalarDB column created from the specified data + * @throws ColumnParsingException if an error occurs while creating the column or parsing the + * value + */ + public static Column createColumnFromValue( + DataType dataType, ColumnInfo columnInfo, @Nullable String value) + throws ColumnParsingException { + String columnName = columnInfo.getColumnName(); + try { + switch (dataType) { + case BOOLEAN: + return value != null + ? BooleanColumn.of(columnName, Boolean.parseBoolean(value)) + : BooleanColumn.ofNull(columnName); + case INT: + return value != null + ? IntColumn.of(columnName, Integer.parseInt(value)) + : IntColumn.ofNull(columnName); + case BIGINT: + return value != null + ? BigIntColumn.of(columnName, Long.parseLong(value)) + : BigIntColumn.ofNull(columnName); + case FLOAT: + return value != null + ? FloatColumn.of(columnName, Float.parseFloat(value)) + : FloatColumn.ofNull(columnName); + case DOUBLE: + return value != null + ? DoubleColumn.of(columnName, Double.parseDouble(value)) + : DoubleColumn.ofNull(columnName); + case TEXT: + return value != null ? TextColumn.of(columnName, value) : TextColumn.ofNull(columnName); + case BLOB: + // Source blob values need to be base64 encoded + return value != null + ? BlobColumn.of(columnName, Base64.getDecoder().decode(value)) + : BlobColumn.ofNull(columnName); + default: + throw new AssertionError(); + } + } catch (NumberFormatException e) { + throw new ColumnParsingException( + String.format( + INVALID_NUMBER_FORMAT_FOR_COLUMN_VALUE, + columnName, + columnInfo.getTableName(), + columnInfo.getNamespace()), + e); + } catch (IllegalArgumentException e) { + throw new ColumnParsingException( + String.format( + INVALID_BASE64_ENCODING_FOR_COLUMN_VALUE, + columnName, + columnInfo.getTableName(), + columnInfo.getNamespace()), + e); + } + } +} diff --git a/data-loader/core/src/main/java/com/scalar/db/dataloader/core/util/KeyUtils.java b/data-loader/core/src/main/java/com/scalar/db/dataloader/core/util/KeyUtils.java new file mode 100644 index 0000000000..e5ecc5edd8 --- /dev/null +++ b/data-loader/core/src/main/java/com/scalar/db/dataloader/core/util/KeyUtils.java @@ -0,0 +1,88 @@ +package com.scalar.db.dataloader.core.util; + +import static com.scalar.db.dataloader.core.ErrorMessage.INVALID_COLUMN_NON_EXISTENT; + +import com.scalar.db.api.TableMetadata; +import com.scalar.db.dataloader.core.ColumnInfo; +import com.scalar.db.dataloader.core.ColumnKeyValue; +import com.scalar.db.dataloader.core.exception.ColumnParsingException; +import com.scalar.db.dataloader.core.exception.KeyParsingException; +import com.scalar.db.io.Column; +import com.scalar.db.io.DataType; +import com.scalar.db.io.Key; +import javax.annotation.Nullable; + +/** + * Utility class for creating and managing ScalarDB keys. + * + *

This class provides methods to parse key-value pairs and create ScalarDB key instances. It + * also includes utility methods for handling data types, columns, and potential parsing exceptions. + */ +public final class KeyUtils { + + /** Restrict instantiation via private constructor */ + private KeyUtils() {} + + /** + * Converts a key-value pair, in the format of =, into a ScalarDB Key instance for a + * specific ScalarDB table. + * + *

This method uses the provided table metadata to determine the data type for the key and + * creates a corresponding ScalarDB Key. If the key does not match any column in the table + * metadata, a {@link KeyParsingException} is thrown. + * + * @param columnKeyValue a key-value pair in the format of = + * @param namespace the name of the ScalarDB namespace + * @param tableName the name of the ScalarDB table + * @param tableMetadata metadata for the ScalarDB table + * @return a new ScalarDB Key instance formatted according to the data type + * @throws KeyParsingException if there is an error parsing the key value or if the column does + * not exist + */ + @Nullable + public static Key parseKeyValue( + @Nullable ColumnKeyValue columnKeyValue, + String namespace, + String tableName, + TableMetadata tableMetadata) + throws KeyParsingException { + if (columnKeyValue == null) { + return null; + } + String columnName = columnKeyValue.getColumnName(); + DataType columnDataType = tableMetadata.getColumnDataType(columnName); + if (columnDataType == null) { + throw new KeyParsingException( + String.format(INVALID_COLUMN_NON_EXISTENT, columnName, tableName, namespace)); + } + ColumnInfo columnInfo = + ColumnInfo.builder() + .namespace(namespace) + .tableName(tableName) + .columnName(columnName) + .build(); + return createKey(columnDataType, columnInfo, columnKeyValue.getColumnValue()); + } + + /** + * Creates a ScalarDB key based on the provided data type, column information, and value. + * + *

This method creates a ScalarDB Key instance by converting the column value to the + * appropriate data type and constructing the key using that value. + * + * @param dataType the data type of the specified column + * @param columnInfo the ScalarDB table column information + * @param value the value for the ScalarDB key + * @return a ScalarDB Key instance + * @throws KeyParsingException if there is an error while creating the ScalarDB key + */ + public static Key createKey(DataType dataType, ColumnInfo columnInfo, String value) + throws KeyParsingException { + try { + Column keyValue = ColumnUtils.createColumnFromValue(dataType, columnInfo, value); + return Key.newBuilder().add(keyValue).build(); + } catch (ColumnParsingException e) { + throw new KeyParsingException(e.getMessage(), e); + } + } +} diff --git a/data-loader/core/src/test/java/com/scalar/db/dataloader/core/util/ColumnUtilsTest.java b/data-loader/core/src/test/java/com/scalar/db/dataloader/core/util/ColumnUtilsTest.java new file mode 100644 index 0000000000..3892deea69 --- /dev/null +++ b/data-loader/core/src/test/java/com/scalar/db/dataloader/core/util/ColumnUtilsTest.java @@ -0,0 +1,107 @@ +package com.scalar.db.dataloader.core.util; + +import static com.scalar.db.dataloader.core.ErrorMessage.INVALID_BASE64_ENCODING_FOR_COLUMN_VALUE; +import static com.scalar.db.dataloader.core.ErrorMessage.INVALID_NUMBER_FORMAT_FOR_COLUMN_VALUE; +import static org.junit.jupiter.api.Assertions.*; + +import com.scalar.db.dataloader.core.ColumnInfo; +import com.scalar.db.dataloader.core.exception.ColumnParsingException; +import com.scalar.db.io.BigIntColumn; +import com.scalar.db.io.BlobColumn; +import com.scalar.db.io.BooleanColumn; +import com.scalar.db.io.Column; +import com.scalar.db.io.DataType; +import com.scalar.db.io.DoubleColumn; +import com.scalar.db.io.FloatColumn; +import com.scalar.db.io.IntColumn; +import com.scalar.db.io.TextColumn; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.stream.Stream; +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; + +class ColumnUtilsTest { + + private static final float FLOAT_VALUE = 2.78f; + + private static Stream provideColumnsForCreateColumnFromValue() { + return Stream.of( + Arguments.of(DataType.BOOLEAN, "boolColumn", "true", BooleanColumn.of("boolColumn", true)), + Arguments.of(DataType.BOOLEAN, "boolColumn", null, BooleanColumn.ofNull("boolColumn")), + Arguments.of(DataType.INT, "intColumn", "42", IntColumn.of("intColumn", 42)), + Arguments.of(DataType.INT, "intColumn", null, IntColumn.ofNull("intColumn")), + Arguments.of( + DataType.BIGINT, + "bigintColumn", + "123456789012", + BigIntColumn.of("bigintColumn", 123456789012L)), + Arguments.of(DataType.BIGINT, "bigintColumn", null, BigIntColumn.ofNull("bigintColumn")), + Arguments.of( + DataType.FLOAT, + "floatColumn", + Float.toString(FLOAT_VALUE), + FloatColumn.of("floatColumn", FLOAT_VALUE)), + Arguments.of(DataType.FLOAT, "floatColumn", null, FloatColumn.ofNull("floatColumn")), + Arguments.of( + DataType.DOUBLE, + "doubleColumn", + Double.toString(Math.E), + DoubleColumn.of("doubleColumn", Math.E)), + Arguments.of(DataType.DOUBLE, "doubleColumn", null, DoubleColumn.ofNull("doubleColumn")), + Arguments.of( + DataType.TEXT, + "textColumn", + "Hello, world!", + TextColumn.of("textColumn", "Hello, world!")), + Arguments.of(DataType.TEXT, "textColumn", null, TextColumn.ofNull("textColumn")), + Arguments.of( + DataType.BLOB, + "blobColumn", + Base64.getEncoder().encodeToString("binary".getBytes(StandardCharsets.UTF_8)), + BlobColumn.of("blobColumn", "binary".getBytes(StandardCharsets.UTF_8))), + Arguments.of(DataType.BLOB, "blobColumn", null, BlobColumn.ofNull("blobColumn"))); + } + + @ParameterizedTest + @MethodSource("provideColumnsForCreateColumnFromValue") + void createColumnFromValue_validInput_returnsColumn( + DataType dataType, String columnName, String value, Column expectedColumn) + throws ColumnParsingException { + ColumnInfo columnInfo = ColumnInfo.builder().columnName(columnName).build(); + Column actualColumn = ColumnUtils.createColumnFromValue(dataType, columnInfo, value); + assertEquals(expectedColumn, actualColumn); + } + + @Test + void createColumnFromValue_invalidNumberFormat_throwsNumberFormatException() { + String columnName = "intColumn"; + String value = "not_a_number"; + ColumnInfo columnInfo = + ColumnInfo.builder().namespace("ns").tableName("table").columnName(columnName).build(); + ColumnParsingException exception = + assertThrows( + ColumnParsingException.class, + () -> ColumnUtils.createColumnFromValue(DataType.INT, columnInfo, value)); + assertEquals( + String.format(INVALID_NUMBER_FORMAT_FOR_COLUMN_VALUE, columnName, "table", "ns"), + exception.getMessage()); + } + + @Test + void createColumnFromValue_invalidBase64_throwsBase64Exception() { + String columnName = "blobColumn"; + String value = "invalid_base64"; + ColumnInfo columnInfo = + ColumnInfo.builder().namespace("ns").tableName("table").columnName(columnName).build(); + ColumnParsingException exception = + assertThrows( + ColumnParsingException.class, + () -> ColumnUtils.createColumnFromValue(DataType.BLOB, columnInfo, value)); + assertEquals( + String.format(INVALID_BASE64_ENCODING_FOR_COLUMN_VALUE, columnName, "table", "ns"), + exception.getMessage()); + } +} diff --git a/data-loader/core/src/test/java/com/scalar/db/dataloader/core/util/KeyUtilsTest.java b/data-loader/core/src/test/java/com/scalar/db/dataloader/core/util/KeyUtilsTest.java new file mode 100644 index 0000000000..0043d10bf4 --- /dev/null +++ b/data-loader/core/src/test/java/com/scalar/db/dataloader/core/util/KeyUtilsTest.java @@ -0,0 +1,148 @@ +package com.scalar.db.dataloader.core.util; + +import static com.scalar.db.dataloader.core.ErrorMessage.INVALID_COLUMN_NON_EXISTENT; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import com.scalar.db.api.TableMetadata; +import com.scalar.db.dataloader.core.ColumnInfo; +import com.scalar.db.dataloader.core.ColumnKeyValue; +import com.scalar.db.dataloader.core.exception.KeyParsingException; +import com.scalar.db.io.BigIntColumn; +import com.scalar.db.io.BlobColumn; +import com.scalar.db.io.BooleanColumn; +import com.scalar.db.io.DataType; +import com.scalar.db.io.DoubleColumn; +import com.scalar.db.io.FloatColumn; +import com.scalar.db.io.IntColumn; +import com.scalar.db.io.Key; +import com.scalar.db.io.TextColumn; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class KeyUtilsTest { + + @Mock private TableMetadata tableMetadata; + + @Test + void parseKeyValue_nullKeyValue_returnsNull() throws KeyParsingException { + assertNull(KeyUtils.parseKeyValue(null, null, null, tableMetadata)); + } + + @Test + void parseKeyValue_invalidColumnName_throwsKeyParsingException() { + String columnName = "invalidColumn"; + ColumnKeyValue keyValue = new ColumnKeyValue(columnName, "value"); + when(tableMetadata.getColumnDataType(columnName)).thenReturn(null); + + KeyParsingException exception = + assertThrows( + KeyParsingException.class, + () -> KeyUtils.parseKeyValue(keyValue, "namespace", "table", tableMetadata)); + assertEquals( + String.format(INVALID_COLUMN_NON_EXISTENT, columnName, "table", "namespace"), + exception.getMessage()); + } + + @Test + void parseKeyValue_validKeyValue_returnsKey() throws KeyParsingException { + String columnName = "columnName"; + String value = "value"; + ColumnKeyValue keyValue = new ColumnKeyValue(columnName, value); + DataType dataType = DataType.TEXT; + when(tableMetadata.getColumnDataType(columnName)).thenReturn(dataType); + + Key expected = Key.newBuilder().add(TextColumn.of(columnName, value)).build(); + Key actual = KeyUtils.parseKeyValue(keyValue, "namespace", "table", tableMetadata); + + assertEquals(expected, actual); + } + + @Test + void createKey_boolean_returnsKey() throws KeyParsingException { + String columnName = "booleanColumn"; + String value = "true"; + ColumnInfo columnInfo = ColumnInfo.builder().columnName(columnName).build(); + Key expected = Key.newBuilder().add(BooleanColumn.of(columnName, true)).build(); + Key actual = KeyUtils.createKey(DataType.BOOLEAN, columnInfo, value); + assertEquals(expected, actual); + } + + @Test + void createKey_int_returnsKey() throws KeyParsingException { + String columnName = "intColumn"; + String value = "42"; + ColumnInfo columnInfo = ColumnInfo.builder().columnName(columnName).build(); + Key expected = Key.newBuilder().add(IntColumn.of(columnName, 42)).build(); + Key actual = KeyUtils.createKey(DataType.INT, columnInfo, value); + assertEquals(expected, actual); + } + + @Test + void createKey_bigint_returnsKey() throws KeyParsingException { + String columnName = "bigintColumn"; + String value = "123456789012345"; + ColumnInfo columnInfo = ColumnInfo.builder().columnName(columnName).build(); + Key expected = Key.newBuilder().add(BigIntColumn.of(columnName, 123456789012345L)).build(); + Key actual = KeyUtils.createKey(DataType.BIGINT, columnInfo, value); + assertEquals(expected, actual); + } + + @Test + void createKey_float_returnsKey() throws KeyParsingException { + String columnName = "floatColumn"; + String value = "1.23"; + ColumnInfo columnInfo = ColumnInfo.builder().columnName(columnName).build(); + Key expected = Key.newBuilder().add(FloatColumn.of(columnName, 1.23f)).build(); + Key actual = KeyUtils.createKey(DataType.FLOAT, columnInfo, value); + assertEquals(expected, actual); + } + + @Test + void createKey_double_returnsKey() throws KeyParsingException { + String columnName = "doubleColumn"; + String value = "1.23"; + ColumnInfo columnInfo = ColumnInfo.builder().columnName(columnName).build(); + Key expected = Key.newBuilder().add(DoubleColumn.of(columnName, 1.23)).build(); + Key actual = KeyUtils.createKey(DataType.DOUBLE, columnInfo, value); + assertEquals(expected, actual); + } + + @Test + void createKey_text_returnsKey() throws KeyParsingException { + String columnName = "textColumn"; + String value = "Hello, world!"; + ColumnInfo columnInfo = ColumnInfo.builder().columnName(columnName).build(); + Key expected = Key.newBuilder().add(TextColumn.of(columnName, value)).build(); + Key actual = KeyUtils.createKey(DataType.TEXT, columnInfo, value); + assertEquals(expected, actual); + } + + @Test + void createKey_blob_returnsKey() throws KeyParsingException { + String columnName = "blobColumn"; + ColumnInfo columnInfo = ColumnInfo.builder().columnName(columnName).build(); + String value = + Base64.getEncoder().encodeToString("Hello, world!".getBytes(StandardCharsets.UTF_8)); + Key expected = + Key.newBuilder() + .add(BlobColumn.of(columnName, "Hello, world!".getBytes(StandardCharsets.UTF_8))) + .build(); + Key actual = KeyUtils.createKey(DataType.BLOB, columnInfo, value); + assertEquals(expected, actual); + } + + @Test + void createKey_invalidBase64_throwsBase64Exception() { + String columnName = "blobColumn"; + String value = "invalidBase64"; + ColumnInfo columnInfo = ColumnInfo.builder().columnName(columnName).build(); + assertThrows( + KeyParsingException.class, () -> KeyUtils.createKey(DataType.BLOB, columnInfo, value)); + } +}