Skip to content

Commit

Permalink
Backport to branch(3.10) : [3.10, 3.11, 3.12] Backport data loader (#…
Browse files Browse the repository at this point in the history
…2365)

Co-authored-by: Toshihiro Suzuki <[email protected]>
Co-authored-by: Peckstadt Yves <[email protected]>
  • Loading branch information
3 people authored Nov 27, 2024
1 parent 0998eda commit 350c31a
Show file tree
Hide file tree
Showing 18 changed files with 676 additions and 0 deletions.
27 changes: 27 additions & 0 deletions data-loader/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
subprojects {
group = "scalardb.dataloader"

ext {
apacheCommonsLangVersion = '3.14.0'
apacheCommonsIoVersion = '2.16.1'
}
dependencies {
// AssertJ
testImplementation("org.assertj:assertj-core:${assertjVersion}")

// JUnit 5
testImplementation(platform("org.junit:junit-bom:$junitVersion"))
testImplementation("org.junit.jupiter:junit-jupiter:$junitVersion")
testImplementation("org.junit.jupiter:junit-jupiter-api:${junitVersion}")
testImplementation("org.junit.jupiter:junit-jupiter-engine:${junitVersion}")

// Apache Commons
implementation("org.apache.commons:commons-lang3:${apacheCommonsLangVersion}")
implementation("commons-io:commons-io:${apacheCommonsIoVersion}")

// Mockito
testImplementation "org.mockito:mockito-core:${mockitoVersion}"
testImplementation "org.mockito:mockito-inline:${mockitoVersion}"
testImplementation "org.mockito:mockito-junit-jupiter:${mockitoVersion}"
}
}
59 changes: 59 additions & 0 deletions data-loader/cli/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
plugins {
id 'net.ltgt.errorprone' version "${errorpronePluginVersion}"
id 'com.github.johnrengelman.shadow' version "${shadowPluginVersion}"
id 'com.github.spotbugs' version "${spotbugsPluginVersion}"
id 'application'
}

application {
mainClass = 'com.scalar.db.dataloader.cli.DataLoaderCli'
}

archivesBaseName = "scalardb-data-loader-cli"

dependencies {
implementation project(':core')
implementation project(':data-loader:core')
implementation "org.slf4j:slf4j-simple:${slf4jVersion}"
implementation "info.picocli:picocli:${picocliVersion}"

// for SpotBugs
compileOnly "com.github.spotbugs:spotbugs-annotations:${spotbugsVersion}"
testCompileOnly "com.github.spotbugs:spotbugs-annotations:${spotbugsVersion}"

// for Error Prone
errorprone "com.google.errorprone:error_prone_core:${errorproneVersion}"
errorproneJavac "com.google.errorprone:javac:${errorproneJavacVersion}"
}

javadoc {
title = "ScalarDB Data Loader CLI"
}

// Build a fat jar
shadowJar {
archiveClassifier.set("")
manifest {
attributes 'Main-Class': 'com.scalar.db.dataloader.DataLoaderCli'
}
mergeServiceFiles()
}

spotless {
java {
target 'src/*/java/**/*.java'
importOrder()
removeUnusedImports()
googleJavaFormat(googleJavaFormatVersion)
}
}

spotbugsMain.reports {
html.enabled = true
}
spotbugsMain.excludeFilter = file("${project.rootDir}/gradle/spotbugs-exclude.xml")

spotbugsTest.reports {
html.enabled = true
}
spotbugsTest.excludeFilter = file("${project.rootDir}/gradle/spotbugs-exclude.xml")
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.scalar.db.dataloader.cli;

import com.scalar.db.dataloader.cli.command.dataexport.ExportCommand;
import com.scalar.db.dataloader.cli.command.dataimport.ImportCommand;
import picocli.CommandLine;

/** The main class to start the ScalarDB Data loader CLI tool */
@CommandLine.Command(
description = "ScalarDB Data Loader CLI",
mixinStandardHelpOptions = true,
version = "1.0",
subcommands = {ImportCommand.class, ExportCommand.class})
public class DataLoaderCli {

/**
* Main method to start the ScalarDB Data Loader CLI tool
*
* @param args the command line arguments
*/
public static void main(String[] args) {
int exitCode =
new CommandLine(new DataLoaderCli())
.setCaseInsensitiveEnumValuesAllowed(true)
.execute(args);
System.exit(exitCode);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.scalar.db.dataloader.cli.command;

import com.scalar.db.dataloader.core.ColumnKeyValue;
import picocli.CommandLine;

/**
* Converts a string representation of a key-value pair into a {@link ColumnKeyValue} object. The
* string format should be "key=value".
*/
public class ColumnKeyValueConverter implements CommandLine.ITypeConverter<ColumnKeyValue> {

/**
* Converts a string representation of a key-value pair into a {@link ColumnKeyValue} object.
*
* @param keyValue the string representation of the key-value pair in the format "key=value"
* @return a {@link ColumnKeyValue} object representing the key-value pair
* @throws IllegalArgumentException if the input string is not in the expected format
*/
@Override
public ColumnKeyValue convert(String keyValue) {
if (keyValue == null) {
throw new IllegalArgumentException("Key-value cannot be null");
}
String[] parts = keyValue.split("=", 2);
if (parts.length != 2 || parts[0].trim().isEmpty() || parts[1].trim().isEmpty()) {
throw new IllegalArgumentException("Invalid key-value format: " + keyValue);
}
String columnName = parts[0].trim();
String value = parts[1].trim();
return new ColumnKeyValue(columnName, value);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package com.scalar.db.dataloader.cli.command.dataexport;

import com.scalar.db.dataloader.cli.exception.DirectoryValidationException;
import com.scalar.db.dataloader.cli.exception.InvalidFileExtensionException;
import com.scalar.db.dataloader.cli.util.DirectoryUtils;
import java.io.File;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.Callable;
import javax.annotation.Nullable;
import org.apache.commons.io.FilenameUtils;
import org.apache.commons.lang3.StringUtils;
import picocli.CommandLine;
import picocli.CommandLine.Model.CommandSpec;
import picocli.CommandLine.Spec;

@CommandLine.Command(name = "export", description = "Export data from a ScalarDB table")
public class ExportCommand extends ExportCommandOptions implements Callable<Integer> {

private static final List<String> ALLOWED_EXTENSIONS = Arrays.asList("csv", "json", "jsonl");

@Spec CommandSpec spec;

@Override
public Integer call() throws Exception {
validateOutputDirectory(outputFilePath);
return 0;
}

private void validateOutputDirectory(@Nullable String path)
throws DirectoryValidationException, InvalidFileExtensionException {
if (path == null || path.isEmpty()) {
// It is ok for the output file path to be null or empty as a default file name will be used
// if not provided
return;
}

File file = new File(path);

if (file.isDirectory()) {
validateDirectory(path);
} else {
validateFileExtension(file.getName());
validateDirectory(file.getParent());
}
}

private void validateDirectory(String directoryPath) throws DirectoryValidationException {
// If the directory path is null or empty, use the current working directory
if (directoryPath == null || directoryPath.isEmpty()) {
DirectoryUtils.validateTargetDirectory(DirectoryUtils.getCurrentWorkingDirectory());
} else {
DirectoryUtils.validateTargetDirectory(directoryPath);
}
}

private void validateFileExtension(String filename) throws InvalidFileExtensionException {
String extension = FilenameUtils.getExtension(filename);
if (StringUtils.isBlank(extension)) {
throw new InvalidFileExtensionException(
String.format("No file extension was found on the provided file name %s.", filename));
}
if (!ALLOWED_EXTENSIONS.contains(extension.toLowerCase())) {
throw new InvalidFileExtensionException(
String.format(
"Invalid file extension: %s. Allowed extensions are: %s",
extension, String.join(", ", ALLOWED_EXTENSIONS)));
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.scalar.db.dataloader.cli.command.dataexport;

import picocli.CommandLine;

/** A class to represent the command options for the export command. */
public class ExportCommandOptions {

@CommandLine.Option(
names = {"--output-file", "-o"},
paramLabel = "<OUTPUT_FILE>",
description =
"Path and name of the output file for the exported data (default: <table_name>.<format>)")
protected String outputFilePath;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.scalar.db.dataloader.cli.command.dataimport;

import java.util.concurrent.Callable;
import picocli.CommandLine;

@CommandLine.Command(name = "import", description = "Import data into a ScalarDB table")
public class ImportCommand extends ImportCommandOptions implements Callable<Integer> {
@CommandLine.Spec CommandLine.Model.CommandSpec spec;

@Override
public Integer call() throws Exception {
return 0;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package com.scalar.db.dataloader.cli.command.dataimport;

public class ImportCommandOptions {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.scalar.db.dataloader.cli.exception;

/** Exception thrown when there is an error validating a directory. */
public class DirectoryValidationException extends Exception {

public DirectoryValidationException(String message) {
super(message);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.scalar.db.dataloader.cli.exception;

/** An exception thrown when the file extension is invalid. */
public class InvalidFileExtensionException extends Exception {

public InvalidFileExtensionException(String message) {
super(message);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package com.scalar.db.dataloader.cli.util;

import com.scalar.db.dataloader.cli.exception.DirectoryValidationException;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import org.apache.commons.lang3.StringUtils;

/** Utility class for validating and handling directories. */
public final class DirectoryUtils {

private DirectoryUtils() {
// restrict instantiation
}

/**
* Validates the provided directory path. Ensures that the directory exists and is writable. If
* the directory doesn't exist, a creation attempt is made.
*
* @param directoryPath the directory path to validate
* @throws DirectoryValidationException if the directory is not writable or cannot be created
*/
public static void validateTargetDirectory(String directoryPath)
throws DirectoryValidationException {
if (StringUtils.isBlank(directoryPath)) {
throw new IllegalArgumentException("Directory path cannot be null or empty.");
}

Path path = Paths.get(directoryPath);

if (Files.exists(path)) {
// Check if the provided directory is writable
if (!Files.isWritable(path)) {
throw new DirectoryValidationException(
String.format(
"The directory '%s' does not have write permissions. Please ensure that the current user has write access to the directory.",
path.toAbsolutePath()));
}

} else {
// Create the directory if it doesn't exist
try {
Files.createDirectories(path);
} catch (IOException e) {
throw new DirectoryValidationException(
String.format(
"Failed to create the directory '%s'. Please check if you have sufficient permissions and if there are any file system restrictions. Details: %s",
path.toAbsolutePath(), e.getMessage()));
}
}
}

/**
* Returns the current working directory.
*
* @return the current working directory
*/
public static String getCurrentWorkingDirectory() {
return Paths.get(System.getProperty("user.dir")).toAbsolutePath().toString();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package com.scalar.db.dataloader.cli.command;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

import com.scalar.db.dataloader.core.ColumnKeyValue;
import org.junit.jupiter.api.Test;

class ColumnKeyValueConverterTest {

private final ColumnKeyValueConverter converter = new ColumnKeyValueConverter();

@Test
void convert_ValidInput_ReturnsColumnKeyValue() {
String input = "name=John Doe";
ColumnKeyValue expected = new ColumnKeyValue("name", "John Doe");
ColumnKeyValue result = converter.convert(input);
assertEquals(expected.getColumnName(), result.getColumnName());
assertEquals(expected.getColumnValue(), result.getColumnValue());
}

@Test
void convert_ValidInputWithExtraSpaces_ReturnsColumnKeyValue() {
String input = " age = 25 ";
ColumnKeyValue expected = new ColumnKeyValue("age", "25");
ColumnKeyValue result = converter.convert(input);
assertEquals(expected.getColumnName(), result.getColumnName());
assertEquals(expected.getColumnValue(), result.getColumnValue());
}

@Test
void convert_InvalidInputMissingValue_ThrowsIllegalArgumentException() {
String input = "name=";
assertThrows(IllegalArgumentException.class, () -> converter.convert(input));
}

@Test
void convert_InvalidInputMissingKey_ThrowsIllegalArgumentException() {
String input = "=John Doe";
assertThrows(IllegalArgumentException.class, () -> converter.convert(input));
}

@Test
void convert_InvalidInputMissingEquals_ThrowsIllegalArgumentException() {
String input = "nameJohn Doe";
assertThrows(IllegalArgumentException.class, () -> converter.convert(input));
}

@Test
void convert_ValidInputMultipleEquals_Returns() {
String input = "name=John=Doe";
ColumnKeyValue expected = new ColumnKeyValue("name", "John=Doe");
ColumnKeyValue result = converter.convert(input);
assertEquals(expected.getColumnName(), result.getColumnName());
assertEquals(expected.getColumnValue(), result.getColumnValue());
}

@Test
void convert_NullValue_ThrowsIllegalArgumentException() {
assertThrows(IllegalArgumentException.class, () -> converter.convert(null));
}
}
Loading

0 comments on commit 350c31a

Please sign in to comment.