diff --git a/client/build.gradle b/client/build.gradle index 4e0b97d5..2b6d3b43 100644 --- a/client/build.gradle +++ b/client/build.gradle @@ -1,4 +1,5 @@ plugins { + id 'java-test-fixtures' id 'com.palantir.docker' version "${dockerPluginVersion}" id 'net.ltgt.errorprone' version "${errorpronePluginVersion}" id "com.github.spotbugs" version "${spotbugsPluginVersion}" @@ -33,6 +34,10 @@ dependencies { api group: 'org.glassfish', name: 'javax.json', version: "${jsonpVersion}" api group: 'com.fasterxml.jackson.core', name: 'jackson-databind', version: "${jacksonVersion}" + // for tests + testFixturesImplementation group: 'info.picocli', name: 'picocli', version: "${picoCliVersion}" + testFixturesImplementation group: 'org.assertj', name: 'assertj-core', version: "${assertjVersion}" + // for Error Prone errorprone "com.google.errorprone:error_prone_core:${errorproneVersion}" errorproneJavac "com.google.errorprone:javac:${errorproneJavacVersion}" diff --git a/client/src/main/java/com/scalar/dl/client/error/ClientError.java b/client/src/main/java/com/scalar/dl/client/error/ClientError.java index c0b25418..55588e6a 100644 --- a/client/src/main/java/com/scalar/dl/client/error/ClientError.java +++ b/client/src/main/java/com/scalar/dl/client/error/ClientError.java @@ -114,6 +114,8 @@ public enum ClientError implements ScalarDlError { StatusCode.RUNTIME_ERROR, "003", "Shutting down the channel failed. Details: %s", "", ""), PROCESSING_JSON_FAILED( StatusCode.RUNTIME_ERROR, "004", "Processing JSON failed. Details: %s", "", ""), + CLASS_FILE_LOAD_FAILED( + StatusCode.RUNTIME_ERROR, "005", "Failed to load the class file. File: %s", "", ""), ; private static final String COMPONENT_NAME = "DL-CLIENT"; diff --git a/client/src/main/java/com/scalar/dl/client/tool/Common.java b/client/src/main/java/com/scalar/dl/client/tool/Common.java index 431b5bef..8827e367 100644 --- a/client/src/main/java/com/scalar/dl/client/tool/Common.java +++ b/client/src/main/java/com/scalar/dl/client/tool/Common.java @@ -24,7 +24,7 @@ public class Common { static final String SCALARDL_GC_SUBCOMMAND_NAME = "generic-contracts"; static final String SCALARDL_GC_ALIAS = "gc"; - static JsonNode getValidationResult(LedgerValidationResult result) { + public static JsonNode getValidationResult(LedgerValidationResult result) { ObjectNode json = mapper.createObjectNode().put(Common.STATUS_CODE_KEY, result.getCode().toString()); json.set("Ledger", getProof(result.getLedgerProof().orElse(null))); @@ -55,7 +55,7 @@ static void printOutput(@Nullable JsonNode value) { printJson(json); } - static void printError(ClientException e) { + public static void printError(ClientException e) { JsonNode json = mapper .createObjectNode() @@ -64,7 +64,7 @@ static void printError(ClientException e) { printJson(json); } - static void printJson(JsonNode json) { + public static void printJson(JsonNode json) { try { System.out.println(mapper.writeValueAsString(json)); } catch (JsonProcessingException e) { diff --git a/client/src/main/java/com/scalar/dl/client/tool/CommonOptions.java b/client/src/main/java/com/scalar/dl/client/tool/CommonOptions.java index 3c16da08..e1f62602 100644 --- a/client/src/main/java/com/scalar/dl/client/tool/CommonOptions.java +++ b/client/src/main/java/com/scalar/dl/client/tool/CommonOptions.java @@ -15,26 +15,26 @@ public class CommonOptions { names = {"-h", "--help"}, usageHelp = true, description = "display the help message.") - boolean helpRequested; + protected boolean helpRequested; @CommandLine.Option( names = {"--properties", "--config"}, required = true, paramLabel = "PROPERTIES_FILE", description = "A configuration file in properties format.") - String properties; + protected String properties; @CommandLine.Option( names = {"--stacktrace"}, description = "output Java Stack Trace to stderr stream.") - boolean stacktraceEnabled; + protected boolean stacktraceEnabled; @CommandLine.Option( names = {"-g", "--use-gateway"}, paramLabel = "USE_GATEWAY", defaultValue = "false", description = "A flag to use the gateway.") - boolean useGateway; + protected boolean useGateway; /** * Outputs Java stack trace to stderr stream by using {@link Exception#printStackTrace()} when the diff --git a/client/src/main/java/com/scalar/dl/client/util/Common.java b/client/src/main/java/com/scalar/dl/client/util/Common.java index b91b172d..061d0f62 100644 --- a/client/src/main/java/com/scalar/dl/client/util/Common.java +++ b/client/src/main/java/com/scalar/dl/client/util/Common.java @@ -2,11 +2,14 @@ import com.scalar.dl.client.error.ClientError; import com.scalar.dl.client.exception.ClientException; +import java.io.ByteArrayOutputStream; import java.io.File; import java.io.IOException; +import java.io.InputStream; import java.nio.file.Files; public class Common { + private static final int CLASS_LOAD_BUFFER_SIZE = 4096; public static byte[] fileToBytes(String filePath) { try { @@ -15,4 +18,24 @@ public static byte[] fileToBytes(String filePath) { throw new ClientException(ClientError.READING_FILE_FAILED, e, filePath, e.getMessage()); } } + + public static byte[] getClassBytes(Class clazz) { + String classResourcePath = clazz.getName().replace('.', '/') + ".class"; + try (InputStream is = clazz.getClassLoader().getResourceAsStream(classResourcePath)) { + if (is == null) { + throw new ClientException(ClientError.CLASS_FILE_LOAD_FAILED, clazz.getName()); + } + + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + byte[] tmp = new byte[CLASS_LOAD_BUFFER_SIZE]; + int bytesRead; + while ((bytesRead = is.read(tmp)) != -1) { + buffer.write(tmp, 0, bytesRead); + } + return buffer.toByteArray(); + } catch (IOException e) { + throw new RuntimeException( + ClientError.CLASS_FILE_LOAD_FAILED.buildMessage(clazz.getName()), e); + } + } } diff --git a/client/src/test/java/com/scalar/dl/client/tool/CommandLineTestUtils.java b/client/src/testFixtures/java/com/scalar/dl/client/tool/CommandLineTestUtils.java similarity index 100% rename from client/src/test/java/com/scalar/dl/client/tool/CommandLineTestUtils.java rename to client/src/testFixtures/java/com/scalar/dl/client/tool/CommandLineTestUtils.java diff --git a/common/src/main/java/com/scalar/dl/genericcontracts/table/v1_0_0/Constants.java b/common/src/main/java/com/scalar/dl/genericcontracts/table/v1_0_0/Constants.java index 83417948..adc4f12f 100644 --- a/common/src/main/java/com/scalar/dl/genericcontracts/table/v1_0_0/Constants.java +++ b/common/src/main/java/com/scalar/dl/genericcontracts/table/v1_0_0/Constants.java @@ -11,6 +11,8 @@ public class Constants { public static final String CONTRACT_INSERT = PACKAGE + "." + VERSION + ".Insert"; public static final String CONTRACT_SELECT = PACKAGE + "." + VERSION + ".Select"; public static final String CONTRACT_UPDATE = PACKAGE + "." + VERSION + ".Update"; + public static final String CONTRACT_SHOW_TABLES = PACKAGE + "." + VERSION + ".ShowTables"; + public static final String CONTRACT_GET_HISTORY = PACKAGE + "." + VERSION + ".GetHistory"; public static final String CONTRACT_GET_ASSET_ID = PACKAGE + "." + VERSION + ".GetAssetId"; public static final String CONTRACT_SCAN = PACKAGE + "." + VERSION + ".Scan"; diff --git a/table-store/build.gradle b/table-store/build.gradle index 03134369..dc3cac8e 100644 --- a/table-store/build.gradle +++ b/table-store/build.gradle @@ -3,11 +3,19 @@ plugins { id "com.github.spotbugs" version "${spotbugsPluginVersion}" } +apply plugin:'application' +startScripts.enabled = false + dependencies { implementation project(':client') implementation project(':generic-contracts') + implementation project(':ledger') // to use JacksonBasedContract + implementation group: 'info.picocli', name: 'picocli', version: "${picoCliVersion}" implementation group: 'org.partiql', name: 'partiql-parser', version: "${partiqlVersion}" + // for test + testImplementation testFixtures(project(':client')) // to use CommandLineTestUtils + // for Error Prone errorprone "com.google.errorprone:error_prone_core:${errorproneVersion}" errorproneJavac "com.google.errorprone:javac:${errorproneJavacVersion}" @@ -18,6 +26,18 @@ dependencies { testCompileOnly "com.github.spotbugs:spotbugs-annotations:${spotbugsVersion}" } +task TableStore(type: CreateStartScripts) { + mainClass = 'com.scalar.dl.tablestore.client.tool.TableStoreCommandLine' + applicationName = 'scalardl-table-store' + outputDir = new File(project.buildDir, 'tmp') + classpath = jar.outputs.files + project.configurations.runtimeClasspath +} + +applicationDistribution.into('bin') { + from(TableStore) + fileMode = 0755 +} + spotless { java { target 'src/*/java/**/*.java' diff --git a/table-store/src/main/java/com/scalar/dl/tablestore/client/error/TableStoreClientError.java b/table-store/src/main/java/com/scalar/dl/tablestore/client/error/TableStoreClientError.java index 53c2d867..e4363b45 100644 --- a/table-store/src/main/java/com/scalar/dl/tablestore/client/error/TableStoreClientError.java +++ b/table-store/src/main/java/com/scalar/dl/tablestore/client/error/TableStoreClientError.java @@ -170,6 +170,8 @@ public enum TableStoreClientError implements ScalarDlError { "The limit clause is not supported except in the history query.", "", ""), + MULTIPLE_STATEMENTS_NOT_SUPPORTED( + StatusCode.INVALID_ARGUMENT, "028", "Multiple statements are not supported.", "", ""), ; private static final String COMPONENT_NAME = "DL-TABLE-STORE"; diff --git a/table-store/src/main/java/com/scalar/dl/tablestore/client/model/StatementExecutionResult.java b/table-store/src/main/java/com/scalar/dl/tablestore/client/model/StatementExecutionResult.java new file mode 100644 index 00000000..e21e1361 --- /dev/null +++ b/table-store/src/main/java/com/scalar/dl/tablestore/client/model/StatementExecutionResult.java @@ -0,0 +1,96 @@ +package com.scalar.dl.tablestore.client.model; + +import com.google.common.collect.ImmutableList; +import com.scalar.dl.ledger.model.ContractExecutionResult; +import com.scalar.dl.ledger.proof.AssetProof; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import javax.annotation.concurrent.Immutable; + +/** + * The result of statement execution. It contains the result of the statement execution along with a + * list of {@link AssetProof}s from Ledger and Auditor. + */ +@Immutable +// non-final for mocking +public class StatementExecutionResult { + private final String result; + private final ImmutableList ledgerProofs; + private final ImmutableList auditorProofs; + + /** + * Constructs a {@code StatementExecutionResult} using the specified {@code + * ContractExecutionResult} + * + * @param contractExecutionResult a {@code ContractExecutionResult} + */ + public StatementExecutionResult(ContractExecutionResult contractExecutionResult) { + this.result = contractExecutionResult.getContractResult().orElse(null); + this.ledgerProofs = ImmutableList.copyOf(contractExecutionResult.getLedgerProofs()); + this.auditorProofs = ImmutableList.copyOf(contractExecutionResult.getAuditorProofs()); + } + + /** + * Returns the result of statement execution. + * + * @return the result of statement execution + */ + public Optional getResult() { + return Optional.ofNullable(result); + } + + /** + * Returns the list of {@link AssetProof}s from Ledger. + * + * @return the list of {@link AssetProof}s from Ledger + */ + public List getLedgerProofs() { + return ledgerProofs; + } + + /** + * Returns the list of {@link AssetProof}s from Auditor. + * + * @return the list of {@link AssetProof}s from Auditor + */ + public List getAuditorProofs() { + return auditorProofs; + } + + /** + * Returns a hash code value for the object. + * + * @return a hash code value for this object. + */ + @Override + public int hashCode() { + return Objects.hash(result, ledgerProofs, auditorProofs); + } + + /** + * Indicates whether some other object is "equal to" this object. The other object is considered + * equal if it is the same instance or if: + * + *
    + *
  • it is also an {@code StatementExecutionResult} and + *
  • both instances have the same result and proofs. + *
+ * + * @param o an object to be tested for equality + * @return {@code true} if the other object is "equal to" this object otherwise {@code false} + */ + @Override + public boolean equals(Object o) { + if (o == this) { + return true; + } + if (!(o instanceof StatementExecutionResult)) { + return false; + } + StatementExecutionResult other = (StatementExecutionResult) o; + return Objects.equals(this.result, other.result) + && this.ledgerProofs.equals(other.ledgerProofs) + && this.auditorProofs.equals(other.auditorProofs); + } +} diff --git a/table-store/src/main/java/com/scalar/dl/tablestore/client/service/ClientService.java b/table-store/src/main/java/com/scalar/dl/tablestore/client/service/ClientService.java new file mode 100644 index 00000000..444e66c2 --- /dev/null +++ b/table-store/src/main/java/com/scalar/dl/tablestore/client/service/ClientService.java @@ -0,0 +1,423 @@ +package com.scalar.dl.tablestore.client.service; + +import static com.scalar.dl.genericcontracts.table.v1_0_0.Constants.ASSET_ID_SEPARATOR; +import static com.scalar.dl.genericcontracts.table.v1_0_0.Constants.CONTRACT_CREATE; +import static com.scalar.dl.genericcontracts.table.v1_0_0.Constants.CONTRACT_GET_ASSET_ID; +import static com.scalar.dl.genericcontracts.table.v1_0_0.Constants.CONTRACT_GET_HISTORY; +import static com.scalar.dl.genericcontracts.table.v1_0_0.Constants.CONTRACT_INSERT; +import static com.scalar.dl.genericcontracts.table.v1_0_0.Constants.CONTRACT_SCAN; +import static com.scalar.dl.genericcontracts.table.v1_0_0.Constants.CONTRACT_SELECT; +import static com.scalar.dl.genericcontracts.table.v1_0_0.Constants.CONTRACT_SHOW_TABLES; +import static com.scalar.dl.genericcontracts.table.v1_0_0.Constants.CONTRACT_UPDATE; +import static com.scalar.dl.genericcontracts.table.v1_0_0.Constants.PREFIX_INDEX; +import static com.scalar.dl.genericcontracts.table.v1_0_0.Constants.PREFIX_RECORD; +import static com.scalar.dl.genericcontracts.table.v1_0_0.Constants.PREFIX_TABLE; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ValueNode; +import com.google.common.collect.ImmutableMap; +import com.scalar.dl.client.exception.ClientException; +import com.scalar.dl.client.service.ClientServiceFactory; +import com.scalar.dl.client.util.Common; +import com.scalar.dl.genericcontracts.table.v1_0_0.Create; +import com.scalar.dl.genericcontracts.table.v1_0_0.GetAssetId; +import com.scalar.dl.genericcontracts.table.v1_0_0.GetHistory; +import com.scalar.dl.genericcontracts.table.v1_0_0.Insert; +import com.scalar.dl.genericcontracts.table.v1_0_0.Scan; +import com.scalar.dl.genericcontracts.table.v1_0_0.Select; +import com.scalar.dl.genericcontracts.table.v1_0_0.ShowTables; +import com.scalar.dl.genericcontracts.table.v1_0_0.Update; +import com.scalar.dl.ledger.model.ContractExecutionResult; +import com.scalar.dl.ledger.model.LedgerValidationResult; +import com.scalar.dl.ledger.service.StatusCode; +import com.scalar.dl.ledger.util.JacksonSerDe; +import com.scalar.dl.tablestore.client.error.TableStoreClientError; +import com.scalar.dl.tablestore.client.model.StatementExecutionResult; +import com.scalar.dl.tablestore.client.partiql.parser.ScalarPartiqlParser; +import com.scalar.dl.tablestore.client.partiql.statement.ContractStatement; +import java.util.List; +import java.util.Map; +import javax.annotation.concurrent.Immutable; +import javax.json.JsonObject; +import javax.json.JsonValue; + +/** + * A thread-safe client for a table store. The client interacts with Ledger and Auditor components + * to register certificates, register contracts, execute statements, and validate data. + * + *

Usage Examples

+ * + * Here is a simple example to demonstrate how to use {@code ClientService}. {@code ClientService} + * should always be created with {@link ClientServiceFactory}, which reuses internal instances as + * much as possible for better performance and less resource usage. When you create {@code + * ClientService}, the client certificate or secret key and the necessary contracts for using a + * table store are automatically registered by default based on the configuration in {@code + * ClientConfig}. + * + *
{@code
+ * ClientServiceFactory factory = new ClientServiceFactory(); // the factory should be reused
+ *
+ * ClientService service = factory.create(new ClientConfig(new File(properties));
+ * try {
+ *   String statement = ...; // prepare a PartiQL statement
+ *   StatementExecutionResult result = service.executeStatement(statement);
+ *   result.getResult().ifPresent(System.out::println);
+ * } catch (ClientException e) {
+ *   System.err.println(e.getStatusCode());
+ *   System.err.println(e.getMessage());
+ * }
+ *
+ * factory.close();
+ * }
+ */ +@Immutable +public class ClientService { + private static final ImmutableMap> CONTRACTS = + ImmutableMap.>builder() + .put(CONTRACT_CREATE, Create.class) + .put(CONTRACT_INSERT, Insert.class) + .put(CONTRACT_SELECT, Select.class) + .put(CONTRACT_UPDATE, Update.class) + .put(CONTRACT_SHOW_TABLES, ShowTables.class) + .put(CONTRACT_GET_HISTORY, GetHistory.class) + .put(CONTRACT_GET_ASSET_ID, GetAssetId.class) + .put(CONTRACT_SCAN, Scan.class) + .build(); + private static final JacksonSerDe jacksonSerDe = new JacksonSerDe(new ObjectMapper()); + private final com.scalar.dl.client.service.ClientService clientService; + + /** + * Constructs a {@code GenericContractClientService} with the specified {@link ClientService}. + * + * @param clientService a client service + */ + public ClientService(com.scalar.dl.client.service.ClientService clientService) { + this.clientService = clientService; + } + + /** + * Registers the certificate specified in the given {@code ClientConfig} for digital signature + * authentication. + * + * @throws ClientException if a request fails for some reason + */ + public void registerCertificate() { + clientService.registerCertificate(); + } + + /** + * Registers the secret key specified in the given {@code ClientConfig} for HMAC authentication. + * + * @throws ClientException if a request fails for some reason + */ + public void registerSecret() { + clientService.registerSecret(); + } + + /** + * Registers the predefined contracts for the table store client with the identity specified in + * {@code ClientConfig}. If a contract is already registered, it is simply skipped without + * throwing an exception. + * + * @throws ClientException if a request fails for some reason + */ + public void registerContracts() { + for (Map.Entry> entry : CONTRACTS.entrySet()) { + String contractId = entry.getKey(); + Class clazz = entry.getValue(); + String contractBinaryName = clazz.getName(); + byte[] bytes = Common.getClassBytes(clazz); + try { + clientService.registerContract(contractId, contractBinaryName, bytes, (String) null); + } catch (ClientException e) { + if (!e.getStatusCode().equals(StatusCode.CONTRACT_ALREADY_REGISTERED)) { + throw e; + } + } + } + } + + /** + * Retrieves a list of contracts for the certificate holder specified in {@code ClientConfig}. If + * specified with a contract ID, it will return the matching contract only. + * + * @param id a contract ID + * @return {@link JsonObject} + * @throws ClientException if a request fails for some reason + */ + public JsonObject listContracts(String id) { + return clientService.listContracts(id); + } + + /** + * Executes the specified statement. + * + * @param statement a PartiQL statement + * @return {@link ContractExecutionResult} + * @throws IllegalArgumentException if the specified statement is invalid + * @throws ClientException if a request fails for some reason + */ + public StatementExecutionResult executeStatement(String statement) { + List contractStatements = ScalarPartiqlParser.parse(statement); + assert !contractStatements.isEmpty(); + + if (contractStatements.size() > 1) { + throw new IllegalArgumentException( + TableStoreClientError.MULTIPLE_STATEMENTS_NOT_SUPPORTED.buildMessage()); + } + + ContractStatement contractStatement = contractStatements.get(0); + ContractExecutionResult contractExecutionResult = + clientService.executeContract( + contractStatement.getContractId(), contractStatement.getArguments()); + return new StatementExecutionResult(contractExecutionResult); + } + + /** + * Validates the schema of the specified table in the ledger. + * + * @param tableName a table name + * @return {@link LedgerValidationResult} + * @throws ClientException if a request fails for some reason + */ + public LedgerValidationResult validateTableSchema(String tableName) { + return clientService.validateLedger(buildTableSchemaAssetId(tableName)); + } + + /** + * Validates the schema of the specified table in the ledger. + * + * @param tableName a table name + * @param startAge an age to be validated from (inclusive) + * @param endAge an age to be validated to (inclusive) + * @return {@link LedgerValidationResult} + * @throws ClientException if a request fails for some reason + */ + public LedgerValidationResult validateTableSchema(String tableName, int startAge, int endAge) { + return clientService.validateLedger(buildTableSchemaAssetId(tableName), startAge, endAge); + } + + /** + * Validates the specified record in the ledger. + * + * @param tableName a table name + * @param columnName a primary key column name + * @param value a primary key column value + * @return {@link LedgerValidationResult} + * @throws ClientException if a request fails for some reason + */ + public LedgerValidationResult validateRecord( + String tableName, String columnName, JsonValue value) { + return clientService.validateLedger( + buildRecordAssetId(tableName, columnName, toStringFrom(value))); + } + + /** + * Validates the specified record in the ledger. + * + * @param tableName a table name + * @param columnName a primary key column name + * @param value a primary key column value + * @param startAge an age to be validated from (inclusive) + * @param endAge an age to be validated to (inclusive) + * @return {@link LedgerValidationResult} + * @throws ClientException if a request fails for some reason + */ + public LedgerValidationResult validateRecord( + String tableName, String columnName, JsonValue value, int startAge, int endAge) { + return clientService.validateLedger( + buildRecordAssetId(tableName, columnName, toStringFrom(value)), startAge, endAge); + } + + /** + * Validates the specified record in the ledger. + * + * @param tableName a table name + * @param columnName a primary key column name + * @param value a primary key column value + * @return {@link LedgerValidationResult} + * @throws ClientException if a request fails for some reason + */ + public LedgerValidationResult validateRecord( + String tableName, String columnName, ValueNode value) { + return clientService.validateLedger( + buildRecordAssetId(tableName, columnName, toStringFrom(value))); + } + + /** + * Validates the specified record in the ledger. + * + * @param tableName a table name + * @param columnName a primary key column name + * @param value a primary key column value + * @param startAge an age to be validated from (inclusive) + * @param endAge an age to be validated to (inclusive) + * @return {@link LedgerValidationResult} + * @throws ClientException if a request fails for some reason + */ + public LedgerValidationResult validateRecord( + String tableName, String columnName, ValueNode value, int startAge, int endAge) { + return clientService.validateLedger( + buildRecordAssetId(tableName, columnName, toStringFrom(value)), startAge, endAge); + } + + /** + * Validates the specified record in the ledger. + * + * @param tableName a table name + * @param columnName a primary key column name + * @param value a primary key column value in a JSON format + * @return {@link LedgerValidationResult} + * @throws ClientException if a request fails for some reason + */ + public LedgerValidationResult validateRecord(String tableName, String columnName, String value) { + return clientService.validateLedger( + buildRecordAssetId(tableName, columnName, toStringFrom(jacksonSerDe.deserialize(value)))); + } + + /** + * Validates the specified record in the ledger. + * + * @param tableName a table name + * @param columnName a primary key column name + * @param value a primary key column value in a JSON format + * @param startAge an age to be validated from (inclusive) + * @param endAge an age to be validated to (inclusive) + * @return {@link LedgerValidationResult} + * @throws ClientException if a request fails for some reason + */ + public LedgerValidationResult validateRecord( + String tableName, String columnName, String value, int startAge, int endAge) { + return clientService.validateLedger( + buildRecordAssetId(tableName, columnName, toStringFrom(jacksonSerDe.deserialize(value))), + startAge, + endAge); + } + + /** + * Validates the specified index record in the ledger. + * + * @param tableName a table name + * @param columnName an index key column name + * @param value an index key column value + * @return {@link LedgerValidationResult} + * @throws ClientException if a request fails for some reason + */ + public LedgerValidationResult validateIndexRecord( + String tableName, String columnName, JsonValue value) { + return clientService.validateLedger( + buildIndexRecordAssetId(tableName, columnName, toStringFrom(value))); + } + + /** + * Validates the specified index record in the ledger. + * + * @param tableName a table name + * @param columnName an index key column name + * @param value an index key column value + * @param startAge an age to be validated from (inclusive) + * @param endAge an age to be validated to (inclusive) + * @return {@link LedgerValidationResult} + * @throws ClientException if a request fails for some reason + */ + public LedgerValidationResult validateIndexRecord( + String tableName, String columnName, JsonValue value, int startAge, int endAge) { + return clientService.validateLedger( + buildIndexRecordAssetId(tableName, columnName, toStringFrom(value)), startAge, endAge); + } + + /** + * Validates the specified index record in the ledger. + * + * @param tableName a table name + * @param columnName an index key column name + * @param value an index key column value + * @return {@link LedgerValidationResult} + * @throws ClientException if a request fails for some reason + */ + public LedgerValidationResult validateIndexRecord( + String tableName, String columnName, ValueNode value) { + return clientService.validateLedger( + buildIndexRecordAssetId(tableName, columnName, toStringFrom(value))); + } + + /** + * Validates the specified index record in the ledger. + * + * @param tableName a table name + * @param columnName an index key column name + * @param value an index key column value + * @param startAge an age to be validated from (inclusive) + * @param endAge an age to be validated to (inclusive) + * @return {@link LedgerValidationResult} + * @throws ClientException if a request fails for some reason + */ + public LedgerValidationResult validateIndexRecord( + String tableName, String columnName, ValueNode value, int startAge, int endAge) { + return clientService.validateLedger( + buildIndexRecordAssetId(tableName, columnName, toStringFrom(value)), startAge, endAge); + } + + /** + * Validates the specified index record in the ledger. + * + * @param tableName a table name + * @param columnName an index key column name + * @param value an index key column value in a JSON format + * @return {@link LedgerValidationResult} + * @throws ClientException if a request fails for some reason + */ + public LedgerValidationResult validateIndexRecord( + String tableName, String columnName, String value) { + return clientService.validateLedger( + buildIndexRecordAssetId( + tableName, columnName, toStringFrom(jacksonSerDe.deserialize(value)))); + } + + /** + * Validates the specified index record in the ledger. + * + * @param tableName a table name + * @param columnName an index key column name + * @param value an index key column value in a JSON format + * @param startAge an age to be validated from (inclusive) + * @param endAge an age to be validated to (inclusive) + * @return {@link LedgerValidationResult} + * @throws ClientException if a request fails for some reason + */ + public LedgerValidationResult validateIndexRecord( + String tableName, String columnName, String value, int startAge, int endAge) { + return clientService.validateLedger( + buildIndexRecordAssetId( + tableName, columnName, toStringFrom(jacksonSerDe.deserialize(value))), + startAge, + endAge); + } + + private String buildTableSchemaAssetId(String tableName) { + return PREFIX_TABLE + tableName; + } + + private String buildRecordAssetId(String tableName, String columnName, String value) { + return PREFIX_RECORD + String.join(ASSET_ID_SEPARATOR, tableName, columnName, value); + } + + private String buildIndexRecordAssetId(String tableName, String columnName, String value) { + return PREFIX_INDEX + String.join(ASSET_ID_SEPARATOR, tableName, columnName, value); + } + + private String toStringFrom(JsonValue value) { + return toStringFrom(jacksonSerDe.deserialize(value.toString())); + } + + private String toStringFrom(JsonNode value) { + if (value.canConvertToExactIntegral()) { + return value.bigIntegerValue().toString(); + } else if (value.isNumber()) { + return String.valueOf(value.doubleValue()); + } else { + return value.asText(); + } + } +} diff --git a/table-store/src/main/java/com/scalar/dl/tablestore/client/service/ClientServiceFactory.java b/table-store/src/main/java/com/scalar/dl/tablestore/client/service/ClientServiceFactory.java new file mode 100644 index 00000000..dfdd1cf0 --- /dev/null +++ b/table-store/src/main/java/com/scalar/dl/tablestore/client/service/ClientServiceFactory.java @@ -0,0 +1,113 @@ +package com.scalar.dl.tablestore.client.service; + +import com.google.common.annotations.VisibleForTesting; +import com.scalar.dl.client.config.ClientConfig; +import com.scalar.dl.client.config.GatewayClientConfig; +import com.scalar.dl.client.exception.ClientException; +import com.scalar.dl.ledger.config.AuthenticationMethod; +import com.scalar.dl.ledger.service.StatusCode; +import javax.annotation.concurrent.ThreadSafe; + +/** + * A factory class to instantiate {@link ClientService}. {@code ClientServiceFactory} creates a new + * {@link ClientService} for each create method call but reuses objects such as clients and + * connections as much as possible on the basis of a give configuration. So, {@code + * ClientServiceFactory} object should always be reused. Please see the Javadoc of {@link + * ClientService} for how to use this. + */ +@ThreadSafe +public class ClientServiceFactory { + private final com.scalar.dl.client.service.ClientServiceFactory clientServiceFactory; + + public ClientServiceFactory() { + clientServiceFactory = new com.scalar.dl.client.service.ClientServiceFactory(); + } + + @VisibleForTesting + ClientServiceFactory(com.scalar.dl.client.service.ClientServiceFactory factory) { + clientServiceFactory = factory; + } + + /** + * Returns a {@link ClientService} instance. + * + * @param config a client config + * @return a {@link ClientService} instance + */ + public ClientService create(ClientConfig config) { + return create(config, true); + } + + /** + * Returns a {@link ClientService} instance. + * + * @param config a client config + * @param autoRegistrationEnabled a boolean flag whether it performs auto registration + * @return a {@link ClientService} instance + */ + public ClientService create(ClientConfig config, boolean autoRegistrationEnabled) { + ClientService clientService = new ClientService(clientServiceFactory.create(config)); + if (autoRegistrationEnabled) { + register(clientService, config); + } + return clientService; + } + + /** + * Returns a {@link ClientService} instance. + * + * @param config a gateway client config + * @return a {@link ClientService} instance + */ + public ClientService create(GatewayClientConfig config) { + return create(config, true); + } + + /** + * Returns a {@link ClientService} instance. + * + * @param config a gateway client config + * @param autoRegistrationEnabled a boolean flag whether it performs auto registration + * @return a {@link ClientService} instance + */ + public ClientService create(GatewayClientConfig config, boolean autoRegistrationEnabled) { + ClientService clientService = new ClientService(clientServiceFactory.create(config)); + if (autoRegistrationEnabled) { + register(clientService, config.getClientConfig()); + } + return clientService; + } + + /** + * Cleans up all the resources managed by the factory. This must be called after finishing up all + * the interactions with the {@link ClientService}s that it creates. + */ + public void close() { + clientServiceFactory.close(); + } + + /** + * Registers the predefined contracts for the table store client, in addition to the certificate + * (for digital signature authentication) or the secret key (HMAC authentication) based on the + * configuration in {@code ClientConfig}. If the certificate, secret, or contracts are already + * registered, they are simply skipped without throwing an exception. + * + * @throws ClientException if a request fails for some reason other than 'already exists' + */ + private void register(ClientService clientService, ClientConfig config) { + try { + if (config.getAuthenticationMethod().equals(AuthenticationMethod.DIGITAL_SIGNATURE)) { + clientService.registerCertificate(); + } else { + clientService.registerSecret(); + } + } catch (ClientException e) { + if (!e.getStatusCode().equals(StatusCode.CERTIFICATE_ALREADY_REGISTERED) + && !e.getStatusCode().equals(StatusCode.SECRET_ALREADY_REGISTERED)) { + throw e; + } + } + + clientService.registerContracts(); + } +} diff --git a/table-store/src/main/java/com/scalar/dl/tablestore/client/tool/ContractsRegistration.java b/table-store/src/main/java/com/scalar/dl/tablestore/client/tool/ContractsRegistration.java new file mode 100644 index 00000000..35389987 --- /dev/null +++ b/table-store/src/main/java/com/scalar/dl/tablestore/client/tool/ContractsRegistration.java @@ -0,0 +1,51 @@ +package com.scalar.dl.tablestore.client.tool; + +import com.google.common.annotations.VisibleForTesting; +import com.scalar.dl.client.config.ClientConfig; +import com.scalar.dl.client.config.GatewayClientConfig; +import com.scalar.dl.client.exception.ClientException; +import com.scalar.dl.client.tool.Common; +import com.scalar.dl.client.tool.CommonOptions; +import com.scalar.dl.tablestore.client.service.ClientService; +import com.scalar.dl.tablestore.client.service.ClientServiceFactory; +import java.io.File; +import java.util.concurrent.Callable; +import picocli.CommandLine; +import picocli.CommandLine.Command; + +@Command(name = "register-contracts", description = "Register all necessary contracts.") +public class ContractsRegistration extends CommonOptions implements Callable { + + public static void main(String[] args) { + int exitCode = new CommandLine(new ContractsRegistration()).execute(args); + System.exit(exitCode); + } + + @Override + public Integer call() throws Exception { + return call(new ClientServiceFactory()); + } + + @VisibleForTesting + Integer call(ClientServiceFactory factory) throws Exception { + ClientService service = + useGateway + ? factory.create(new GatewayClientConfig(new File(properties)), false) + : factory.create(new ClientConfig(new File(properties)), false); + return call(factory, service); + } + + @VisibleForTesting + Integer call(ClientServiceFactory factory, ClientService service) { + try { + service.registerContracts(); + return 0; + } catch (ClientException e) { + Common.printError(e); + printStackTrace(e); + return 1; + } finally { + factory.close(); + } + } +} diff --git a/table-store/src/main/java/com/scalar/dl/tablestore/client/tool/LedgerValidation.java b/table-store/src/main/java/com/scalar/dl/tablestore/client/tool/LedgerValidation.java new file mode 100644 index 00000000..e277995f --- /dev/null +++ b/table-store/src/main/java/com/scalar/dl/tablestore/client/tool/LedgerValidation.java @@ -0,0 +1,109 @@ +package com.scalar.dl.tablestore.client.tool; + +import com.google.common.annotations.VisibleForTesting; +import com.scalar.dl.client.config.ClientConfig; +import com.scalar.dl.client.config.GatewayClientConfig; +import com.scalar.dl.client.exception.ClientException; +import com.scalar.dl.client.tool.Common; +import com.scalar.dl.client.tool.CommonOptions; +import com.scalar.dl.ledger.model.LedgerValidationResult; +import com.scalar.dl.tablestore.client.service.ClientService; +import com.scalar.dl.tablestore.client.service.ClientServiceFactory; +import java.io.File; +import java.util.concurrent.Callable; +import picocli.CommandLine; +import picocli.CommandLine.ArgGroup; +import picocli.CommandLine.Command; + +@Command(name = "validate-ledger", description = "Validate a specified asset in the ledger.") +public class LedgerValidation extends CommonOptions implements Callable { + + @CommandLine.Option( + names = {"--start-age"}, + paramLabel = "START_AGE", + description = "The validation start age of the asset.") + private int startAge = 0; + + @CommandLine.Option( + names = {"--end-age"}, + paramLabel = "END_AGE", + description = "The validation end age of the asset.") + private int endAge = Integer.MAX_VALUE; + + @CommandLine.Option( + names = {"--table-name"}, + required = true, + paramLabel = "TABLE_NAME", + description = "The name of the table.") + String tableName; + + @ArgGroup(exclusive = false, multiplicity = "0..1") + private PrimaryOrIndexKey key; + + static class PrimaryOrIndexKey { + @ArgGroup(exclusive = true, multiplicity = "1") + private ColumnName name; + + @CommandLine.Option( + names = {"--column-value"}, + required = true, + paramLabel = "COLUMN_VALUE", + description = "The column value of the primary key or the index key as a JSON string.") + String value; + } + + static class ColumnName { + @CommandLine.Option( + names = {"--primary-key-column-name"}, + paramLabel = "PRIMARY_KEY_COLUMN_NAME", + description = + "The primary key column name of a record created by table-oriented generic contracts.") + String primary; + + @CommandLine.Option( + names = {"--index-key-column-name"}, + paramLabel = "INDEX_KEY_COLUMN_NAME", + description = + "The index key column name of a record created by table-oriented generic contracts.") + String index; + } + + @Override + public Integer call() throws Exception { + return call(new ClientServiceFactory()); + } + + @VisibleForTesting + Integer call(ClientServiceFactory factory) throws Exception { + ClientService service = + useGateway + ? factory.create(new GatewayClientConfig(new File(properties)), false) + : factory.create(new ClientConfig(new File(properties)), false); + return call(factory, service); + } + + @VisibleForTesting + Integer call(ClientServiceFactory factory, ClientService service) { + try { + LedgerValidationResult result; + if (key == null) { + result = service.validateTableSchema(tableName, startAge, endAge); + } else { + if (key.name.primary != null) { + result = service.validateRecord(tableName, key.name.primary, key.value, startAge, endAge); + } else { + result = + service.validateIndexRecord(tableName, key.name.index, key.value, startAge, endAge); + } + } + Common.printJson(Common.getValidationResult(result)); + return 0; + } catch (ClientException e) { + Common.printError(e); + printStackTrace(e); + return 1; + } finally { + factory.close(); + } + } +} diff --git a/table-store/src/main/java/com/scalar/dl/tablestore/client/tool/StatementExecution.java b/table-store/src/main/java/com/scalar/dl/tablestore/client/tool/StatementExecution.java new file mode 100644 index 00000000..e4d998ef --- /dev/null +++ b/table-store/src/main/java/com/scalar/dl/tablestore/client/tool/StatementExecution.java @@ -0,0 +1,69 @@ +package com.scalar.dl.tablestore.client.tool; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.annotations.VisibleForTesting; +import com.scalar.dl.client.config.ClientConfig; +import com.scalar.dl.client.config.GatewayClientConfig; +import com.scalar.dl.client.exception.ClientException; +import com.scalar.dl.client.tool.Common; +import com.scalar.dl.client.tool.CommonOptions; +import com.scalar.dl.ledger.util.JacksonSerDe; +import com.scalar.dl.tablestore.client.model.StatementExecutionResult; +import com.scalar.dl.tablestore.client.service.ClientService; +import com.scalar.dl.tablestore.client.service.ClientServiceFactory; +import java.io.File; +import java.util.concurrent.Callable; +import picocli.CommandLine; +import picocli.CommandLine.Command; + +@Command(name = "execute-statement", description = "Execute a specified statement.") +public class StatementExecution extends CommonOptions implements Callable { + + @CommandLine.Option( + names = {"--statement"}, + required = true, + paramLabel = "STATEMENT", + description = "A statement to interact with the table store.") + private String statement; + + public static void main(String[] args) { + int exitCode = new CommandLine(new StatementExecution()).execute(args); + System.exit(exitCode); + } + + @Override + public Integer call() throws Exception { + return call(new ClientServiceFactory()); + } + + @VisibleForTesting + Integer call(ClientServiceFactory factory) throws Exception { + ClientService service = + useGateway + ? factory.create(new GatewayClientConfig(new File(properties)), false) + : factory.create(new ClientConfig(new File(properties)), false); + return call(factory, service); + } + + @VisibleForTesting + Integer call(ClientServiceFactory factory, ClientService service) { + JacksonSerDe serde = new JacksonSerDe(new ObjectMapper()); + try { + StatementExecutionResult result = service.executeStatement(statement); + result + .getResult() + .ifPresent( + r -> { + System.out.println("Result:"); + Common.printJson(serde.deserialize(r)); + }); + return 0; + } catch (ClientException e) { + Common.printError(e); + printStackTrace(e); + return 1; + } finally { + factory.close(); + } + } +} diff --git a/table-store/src/main/java/com/scalar/dl/tablestore/client/tool/TableStoreCommandLine.java b/table-store/src/main/java/com/scalar/dl/tablestore/client/tool/TableStoreCommandLine.java new file mode 100644 index 00000000..9fa5b4db --- /dev/null +++ b/table-store/src/main/java/com/scalar/dl/tablestore/client/tool/TableStoreCommandLine.java @@ -0,0 +1,79 @@ +package com.scalar.dl.tablestore.client.tool; + +import static picocli.CommandLine.Model.UsageMessageSpec.SECTION_KEY_COMMAND_LIST; +import static picocli.CommandLine.Model.UsageMessageSpec.SECTION_KEY_COMMAND_LIST_HEADING; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.collect.ImmutableMap; +import com.scalar.dl.client.tool.CertificateRegistration; +import com.scalar.dl.client.tool.CommandGroupRenderer; +import com.scalar.dl.client.tool.ContractsListing; +import com.scalar.dl.client.tool.SecretRegistration; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import picocli.CommandLine; +import picocli.CommandLine.Command; +import picocli.CommandLine.HelpCommand; + +@Command( + name = "scalardl-table-store", + subcommands = { + CertificateRegistration.class, + ContractsListing.class, + ContractsRegistration.class, + HelpCommand.class, + LedgerValidation.class, + SecretRegistration.class, + StatementExecution.class, + }, + description = {"These are ScalarDL Table Store commands used in various situations:"}) +public class TableStoreCommandLine { + + public static void main(String[] args) { + CommandLine commandLine = new CommandLine(new TableStoreCommandLine()); + setupSections(commandLine); + + int exitCode = commandLine.execute(args); + System.exit(exitCode); + } + + /** + * Changes the usage message (a.k.a. help information) of the {@link CommandLine} into the grouped + * sections. + * + *

[Note] + * + *

Please set up all the sections in this method. + * + *

Please refer to the [Usage] comment described in {@link CommandGroupRenderer}. + * + * @param cmd command line of {@link TableStoreCommandLine} + */ + @VisibleForTesting + static void setupSections(CommandLine cmd) { + // ref. Code to group subcommands from the official documentation. + // https://github.com/remkop/picocli/issues/978#issuecomment-604174211 + + ImmutableMap.Builder>> sections = ImmutableMap.builder(); + // Section: register identity information. + sections.put( + "%nregister identity information%n", + Arrays.asList(CertificateRegistration.class, SecretRegistration.class)); + // Section: register business logic. + sections.put( + "%nregister contracts for the table store%n", + Collections.singletonList(ContractsRegistration.class)); + // Section: list the registered contracts. + sections.put( + "%nlist the registered contracts%n", Collections.singletonList(ContractsListing.class)); + // Section: execute a statement. + sections.put("%nexecute a statement%n", Collections.singletonList(StatementExecution.class)); + // Section: validate ledger. + sections.put("%nvalidate ledger%n", Collections.singletonList(LedgerValidation.class)); + CommandGroupRenderer renderer = new CommandGroupRenderer(sections.build()); + + cmd.getHelpSectionMap().remove(SECTION_KEY_COMMAND_LIST_HEADING); + cmd.getHelpSectionMap().put(SECTION_KEY_COMMAND_LIST, renderer); + } +} diff --git a/table-store/src/test/java/com/scalar/dl/tablestore/client/service/ClientServiceFactoryTest.java b/table-store/src/test/java/com/scalar/dl/tablestore/client/service/ClientServiceFactoryTest.java new file mode 100644 index 00000000..36b277f1 --- /dev/null +++ b/table-store/src/test/java/com/scalar/dl/tablestore/client/service/ClientServiceFactoryTest.java @@ -0,0 +1,179 @@ +package com.scalar.dl.tablestore.client.service; + +import static org.assertj.core.api.AssertionsForClassTypes.catchThrowable; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.scalar.dl.client.config.ClientConfig; +import com.scalar.dl.client.config.GatewayClientConfig; +import com.scalar.dl.client.exception.ClientException; +import com.scalar.dl.ledger.config.AuthenticationMethod; +import com.scalar.dl.ledger.service.StatusCode; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +public class ClientServiceFactoryTest { + @Mock private ClientConfig config; + @Mock private GatewayClientConfig gatewayClientConfig; + @Mock private com.scalar.dl.client.service.ClientServiceFactory clientServiceFactory; + @Mock private com.scalar.dl.client.service.ClientService clientService; + private ClientServiceFactory factory; + + @BeforeEach + public void setUp() { + MockitoAnnotations.openMocks(this); + factory = new ClientServiceFactory(clientServiceFactory); + } + + @Test + public void create_ClientConfigGivenAndAutoRegistrationDisabled_ShouldCreateClientService() { + // Arrange + when(clientServiceFactory.create(any(ClientConfig.class))).thenReturn(clientService); + + // Act + factory.create(config, false); + + // Assert + verify(clientServiceFactory).create(config); + verify(clientService, never()).registerCertificate(); + verify(clientService, never()).registerSecret(); + } + + @Test + public void + create_ClientConfigWithDigitalSignatureGivenAndAutoRegistrationEnabled_ShouldCreateClientServiceAndRegisterCertificate() { + // Arrange + when(config.getAuthenticationMethod()).thenReturn(AuthenticationMethod.DIGITAL_SIGNATURE); + when(clientServiceFactory.create(any(ClientConfig.class))).thenReturn(clientService); + + // Act + factory.create(config, true); + + // Assert + verify(clientServiceFactory).create(config); + verify(clientService).registerCertificate(); + verify(clientService, never()).registerSecret(); + } + + @Test + public void + create_ClientConfigWithHmacGivenAndAutoRegistrationEnabled_ShouldCreateClientServiceAndRegisterSecret() { + // Arrange + when(config.getAuthenticationMethod()).thenReturn(AuthenticationMethod.HMAC); + when(clientServiceFactory.create(any(ClientConfig.class))).thenReturn(clientService); + + // Act + factory.create(config, true); + + // Assert + verify(clientServiceFactory).create(config); + verify(clientService, never()).registerCertificate(); + verify(clientService).registerSecret(); + } + + @Test + public void + create_ClientConfigWithDigitalSignatureGivenAndAutoRegistrationNotSpecified_ShouldCreateClientServiceAndRegisterCertificate() { + // Arrange + when(config.getAuthenticationMethod()).thenReturn(AuthenticationMethod.DIGITAL_SIGNATURE); + when(clientServiceFactory.create(any(ClientConfig.class))).thenReturn(clientService); + + // Act + factory.create(config); + + // Assert + verify(clientServiceFactory).create(config); + verify(clientService).registerCertificate(); + verify(clientService, never()).registerSecret(); + } + + @Test + public void + create_ClientExceptionThrownDueToCertificateAlreadyRegistered_ShouldCreateClientService() { + // Arrange + when(config.getAuthenticationMethod()).thenReturn(AuthenticationMethod.DIGITAL_SIGNATURE); + when(clientServiceFactory.create(any(ClientConfig.class))).thenReturn(clientService); + ClientException exception = + new ClientException("cert already registered", StatusCode.CERTIFICATE_ALREADY_REGISTERED); + doThrow(exception).when(clientService).registerCertificate(); + + // Act + factory.create(config); + + // Assert + verify(clientServiceFactory).create(config); + verify(clientService).registerCertificate(); + verify(clientService, never()).registerSecret(); + } + + @Test + public void create_ClientExceptionThrownDueToSecretAlreadyRegistered_ShouldCreateClientService() { + // Arrange + when(config.getAuthenticationMethod()).thenReturn(AuthenticationMethod.HMAC); + when(clientServiceFactory.create(any(ClientConfig.class))).thenReturn(clientService); + ClientException exception = + new ClientException("secret already registered", StatusCode.SECRET_ALREADY_REGISTERED); + doThrow(exception).when(clientService).registerSecret(); + + // Act + factory.create(config); + + // Assert + verify(clientServiceFactory).create(config); + verify(clientService, never()).registerCertificate(); + verify(clientService).registerSecret(); + } + + @Test + public void create_ClientExceptionThrownDueToInvalidRequest_ShouldCreateClientService() { + // Arrange + when(config.getAuthenticationMethod()).thenReturn(AuthenticationMethod.DIGITAL_SIGNATURE); + when(clientServiceFactory.create(any(ClientConfig.class))).thenReturn(clientService); + ClientException exception = new ClientException("invalid request", StatusCode.INVALID_REQUEST); + doThrow(exception).when(clientService).registerCertificate(); + + // Act + Throwable thrown = catchThrowable(() -> factory.create(config)); + + // Assert + Assertions.assertThat(thrown).isEqualTo(exception); + } + + @Test + public void + create_GatewayClientConfigGivenAndAutoRegistrationDisabled_ShouldCreateClientService() { + // Arrange + when(clientServiceFactory.create(any(GatewayClientConfig.class))).thenReturn(clientService); + + // Act + factory.create(gatewayClientConfig, false); + + // Assert + verify(clientServiceFactory).create(gatewayClientConfig); + verify(clientService, never()).registerCertificate(); + verify(clientService, never()).registerSecret(); + } + + @Test + public void + create_GatewayClientConfigWithDigitalSignatureGivenAndAutoRegistrationNotSpecified_ShouldCreateClientServiceAndRegisterCertificate() { + // Arrange + when(config.getAuthenticationMethod()).thenReturn(AuthenticationMethod.DIGITAL_SIGNATURE); + when(gatewayClientConfig.getClientConfig()).thenReturn(config); + when(clientServiceFactory.create(any(GatewayClientConfig.class))).thenReturn(clientService); + + // Act + factory.create(gatewayClientConfig); + + // Assert + verify(clientServiceFactory).create(gatewayClientConfig); + verify(clientService).registerCertificate(); + verify(clientService, never()).registerSecret(); + } +} diff --git a/table-store/src/test/java/com/scalar/dl/tablestore/client/service/ClientServiceTest.java b/table-store/src/test/java/com/scalar/dl/tablestore/client/service/ClientServiceTest.java new file mode 100644 index 00000000..34c503d5 --- /dev/null +++ b/table-store/src/test/java/com/scalar/dl/tablestore/client/service/ClientServiceTest.java @@ -0,0 +1,514 @@ +package com.scalar.dl.tablestore.client.service; + +import static com.scalar.dl.genericcontracts.table.v1_0_0.Constants.ASSET_ID_SEPARATOR; +import static com.scalar.dl.genericcontracts.table.v1_0_0.Constants.CONTRACT_CREATE; +import static com.scalar.dl.genericcontracts.table.v1_0_0.Constants.CONTRACT_GET_ASSET_ID; +import static com.scalar.dl.genericcontracts.table.v1_0_0.Constants.CONTRACT_GET_HISTORY; +import static com.scalar.dl.genericcontracts.table.v1_0_0.Constants.CONTRACT_INSERT; +import static com.scalar.dl.genericcontracts.table.v1_0_0.Constants.CONTRACT_SCAN; +import static com.scalar.dl.genericcontracts.table.v1_0_0.Constants.CONTRACT_SELECT; +import static com.scalar.dl.genericcontracts.table.v1_0_0.Constants.CONTRACT_SHOW_TABLES; +import static com.scalar.dl.genericcontracts.table.v1_0_0.Constants.CONTRACT_UPDATE; +import static com.scalar.dl.genericcontracts.table.v1_0_0.Constants.PREFIX_INDEX; +import static com.scalar.dl.genericcontracts.table.v1_0_0.Constants.PREFIX_RECORD; +import static com.scalar.dl.genericcontracts.table.v1_0_0.Constants.PREFIX_TABLE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowable; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.fasterxml.jackson.databind.node.DecimalNode; +import com.fasterxml.jackson.databind.node.DoubleNode; +import com.fasterxml.jackson.databind.node.IntNode; +import com.fasterxml.jackson.databind.node.TextNode; +import com.scalar.dl.client.exception.ClientException; +import com.scalar.dl.ledger.model.ContractExecutionResult; +import com.scalar.dl.ledger.model.LedgerValidationResult; +import com.scalar.dl.ledger.service.StatusCode; +import com.scalar.dl.tablestore.client.model.StatementExecutionResult; +import java.math.BigDecimal; +import javax.json.Json; +import javax.json.JsonObject; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +public class ClientServiceTest { + private static final String ANY_ID = "id"; + private static final String ANY_TABLE = "tbl"; + private static final String ANY_COLUMN = "col"; + private static final int ANY_START_AGE = 0; + private static final int ANY_END_AGE = 5; + private static final JsonObject ANY_JSON_OBJECT = mock(JsonObject.class); + + @Mock private com.scalar.dl.client.service.ClientService clientService; + @Mock private ContractExecutionResult contractExecutionResult; + @Mock private LedgerValidationResult ledgerValidationResult; + + private ClientService service; + + @BeforeEach + public void setUp() { + MockitoAnnotations.openMocks(this); + service = new ClientService(clientService); + } + + @Test + public void registerCertificate_ShouldCallClientServiceRegisterCertificate() { + // Arrange Act + service.registerCertificate(); + + // Assert + verify(clientService).registerCertificate(); + } + + @Test + public void registerSecret_ShouldCallClientServiceRegisterSecret() { + // Arrange Act + service.registerSecret(); + + // Assert + verify(clientService).registerSecret(); + } + + @Test + public void registerContracts_ShouldRegisterAllPredefinedContracts() { + // Arrange Act + service.registerContracts(); + + // Assert + verify(clientService) + .registerContract(eq(CONTRACT_CREATE), anyString(), any(byte[].class), eq((String) null)); + verify(clientService) + .registerContract(eq(CONTRACT_INSERT), anyString(), any(byte[].class), eq((String) null)); + verify(clientService) + .registerContract(eq(CONTRACT_SELECT), anyString(), any(byte[].class), eq((String) null)); + verify(clientService) + .registerContract(eq(CONTRACT_UPDATE), anyString(), any(byte[].class), eq((String) null)); + verify(clientService) + .registerContract( + eq(CONTRACT_SHOW_TABLES), anyString(), any(byte[].class), eq((String) null)); + verify(clientService) + .registerContract( + eq(CONTRACT_GET_HISTORY), anyString(), any(byte[].class), eq((String) null)); + verify(clientService) + .registerContract( + eq(CONTRACT_GET_ASSET_ID), anyString(), any(byte[].class), eq((String) null)); + verify(clientService) + .registerContract(eq(CONTRACT_SCAN), anyString(), any(byte[].class), eq((String) null)); + } + + @Test + public void registerContracts_ContractAlreadyRegistered_ShouldContinueWithOtherContracts() { + // Arrange + ClientException exception = + new ClientException("Already registered", StatusCode.CONTRACT_ALREADY_REGISTERED); + doThrow(exception) + .when(clientService) + .registerContract(eq(CONTRACT_CREATE), anyString(), any(byte[].class), eq((String) null)); + + // Act + service.registerContracts(); + + // Assert + verify(clientService, times(8)) + .registerContract(anyString(), anyString(), any(byte[].class), eq((String) null)); + } + + @Test + public void registerContracts_OtherException_ShouldThrow() { + // Arrange + ClientException exception = new ClientException("Invalid request", StatusCode.INVALID_REQUEST); + doThrow(exception) + .when(clientService) + .registerContract(eq(CONTRACT_CREATE), anyString(), any(byte[].class), eq((String) null)); + + // Act + Throwable thrown = catchThrowable(() -> service.registerContracts()); + + // Assert + assertThat(thrown).isExactlyInstanceOf(ClientException.class); + assertThat(((ClientException) thrown).getStatusCode()).isEqualTo(StatusCode.INVALID_REQUEST); + } + + @Test + public void listContracts_ShouldCallClientServiceListContracts() { + // Arrange + when(clientService.listContracts(ANY_ID)).thenReturn(ANY_JSON_OBJECT); + + // Act + JsonObject result = service.listContracts(ANY_ID); + + // Assert + verify(clientService).listContracts(ANY_ID); + assertThat(result).isEqualTo(ANY_JSON_OBJECT); + } + + @Test + public void executeStatement_ValidStatement_ShouldReturnStatementExecutionResult() { + // Arrange + String statement = "INSERT INTO test VALUES {}"; + when(clientService.executeContract(eq(CONTRACT_INSERT), any(String.class))) + .thenReturn(contractExecutionResult); + + // Act + StatementExecutionResult result = service.executeStatement(statement); + + // Assert + verify(clientService).executeContract(eq(CONTRACT_INSERT), any(String.class)); + assertThat(result).isNotNull(); + } + + @Test + public void executeStatement_MultipleStatements_ShouldThrowIllegalArgumentException() { + // Arrange + String statement = "INSERT INTO test VALUES {}; INSERT INTO test VALUES {};"; + + // Act + Throwable thrown = catchThrowable(() -> service.executeStatement(statement)); + + // Assert + assertThat(thrown).isExactlyInstanceOf(IllegalArgumentException.class); + } + + @Test + public void validateTableSchema_ShouldCallClientServiceValidateLedgerWithTablePrefix() { + // Arrange + when(clientService.validateLedger(PREFIX_TABLE + ANY_TABLE)).thenReturn(ledgerValidationResult); + + // Act + LedgerValidationResult result = service.validateTableSchema(ANY_TABLE); + + // Assert + verify(clientService).validateLedger(PREFIX_TABLE + ANY_TABLE); + assertThat(result).isEqualTo(ledgerValidationResult); + } + + @Test + public void validateTableSchema_WithAge_ShouldCallClientServiceValidateLedgerWithTablePrefix() { + // Arrange + when(clientService.validateLedger(PREFIX_TABLE + ANY_TABLE, ANY_START_AGE, ANY_END_AGE)) + .thenReturn(ledgerValidationResult); + + // Act + LedgerValidationResult result = + service.validateTableSchema(ANY_TABLE, ANY_START_AGE, ANY_END_AGE); + + // Assert + verify(clientService).validateLedger(PREFIX_TABLE + ANY_TABLE, ANY_START_AGE, ANY_END_AGE); + assertThat(result).isEqualTo(ledgerValidationResult); + } + + @Test + public void + validateRecord_JsonValueGiven_ShouldCallClientServiceValidateLedgerWithRecordPrefix() { + // Arrange + String stringValue = "val"; + String expectedForStringValue = + PREFIX_RECORD + + ANY_TABLE + + ASSET_ID_SEPARATOR + + ANY_COLUMN + + ASSET_ID_SEPARATOR + + stringValue; + int intValue = 1; + String expectedForIntValue = + PREFIX_RECORD + ANY_TABLE + ASSET_ID_SEPARATOR + ANY_COLUMN + ASSET_ID_SEPARATOR + intValue; + double doubleValue = 1.23; + String expectedForDoubleValue = + PREFIX_RECORD + + ANY_TABLE + + ASSET_ID_SEPARATOR + + ANY_COLUMN + + ASSET_ID_SEPARATOR + + doubleValue; + double doubleIntegerValue = 2.0; + String expectedForDoubleIntegerValue = + PREFIX_RECORD + ANY_TABLE + ASSET_ID_SEPARATOR + ANY_COLUMN + ASSET_ID_SEPARATOR + "2"; + BigDecimal bigDecimalValue = new BigDecimal("1.2345678901234567890123456789"); + String expectedForBigDecimalValue = + PREFIX_RECORD + + ANY_TABLE + + ASSET_ID_SEPARATOR + + ANY_COLUMN + + ASSET_ID_SEPARATOR + + bigDecimalValue.doubleValue(); + + // Act + service.validateRecord(ANY_TABLE, ANY_COLUMN, Json.createValue(stringValue)); + service.validateRecord( + ANY_TABLE, ANY_COLUMN, Json.createValue(stringValue), ANY_START_AGE, ANY_END_AGE); + service.validateRecord(ANY_TABLE, ANY_COLUMN, Json.createValue(intValue)); + service.validateRecord(ANY_TABLE, ANY_COLUMN, Json.createValue(doubleValue)); + service.validateRecord(ANY_TABLE, ANY_COLUMN, Json.createValue(doubleIntegerValue)); + service.validateRecord(ANY_TABLE, ANY_COLUMN, Json.createValue(bigDecimalValue)); + + // Assert + verify(clientService).validateLedger(expectedForStringValue); + verify(clientService).validateLedger(expectedForStringValue, ANY_START_AGE, ANY_END_AGE); + verify(clientService).validateLedger(expectedForIntValue); + verify(clientService).validateLedger(expectedForDoubleValue); + verify(clientService).validateLedger(expectedForDoubleIntegerValue); + verify(clientService).validateLedger(expectedForBigDecimalValue); + } + + @Test + public void + validateRecord_ValueNodeGiven_ShouldCallClientServiceValidateLedgerWithRecordPrefix() { + // Arrange + String stringValue = "val"; + String expectedForStringValue = + PREFIX_RECORD + + ANY_TABLE + + ASSET_ID_SEPARATOR + + ANY_COLUMN + + ASSET_ID_SEPARATOR + + stringValue; + int intValue = 1; + String expectedForIntValue = + PREFIX_RECORD + ANY_TABLE + ASSET_ID_SEPARATOR + ANY_COLUMN + ASSET_ID_SEPARATOR + intValue; + double doubleValue = 1.23; + String expectedForDoubleValue = + PREFIX_RECORD + + ANY_TABLE + + ASSET_ID_SEPARATOR + + ANY_COLUMN + + ASSET_ID_SEPARATOR + + doubleValue; + double doubleIntegerValue = 2.0; + String expectedForDoubleIntegerValue = + PREFIX_RECORD + ANY_TABLE + ASSET_ID_SEPARATOR + ANY_COLUMN + ASSET_ID_SEPARATOR + "2"; + BigDecimal bigDecimalValue = new BigDecimal("1.2345678901234567890123456789"); + String expectedForBigDecimalValue = + PREFIX_RECORD + + ANY_TABLE + + ASSET_ID_SEPARATOR + + ANY_COLUMN + + ASSET_ID_SEPARATOR + + bigDecimalValue.doubleValue(); + + // Act + service.validateRecord(ANY_TABLE, ANY_COLUMN, TextNode.valueOf(stringValue)); + service.validateRecord( + ANY_TABLE, ANY_COLUMN, TextNode.valueOf(stringValue), ANY_START_AGE, ANY_END_AGE); + service.validateRecord(ANY_TABLE, ANY_COLUMN, IntNode.valueOf(intValue)); + service.validateRecord(ANY_TABLE, ANY_COLUMN, DoubleNode.valueOf(doubleValue)); + service.validateRecord(ANY_TABLE, ANY_COLUMN, DoubleNode.valueOf(doubleIntegerValue)); + service.validateRecord(ANY_TABLE, ANY_COLUMN, DecimalNode.valueOf(bigDecimalValue)); + + // Assert + verify(clientService).validateLedger(expectedForStringValue); + verify(clientService).validateLedger(expectedForStringValue, ANY_START_AGE, ANY_END_AGE); + verify(clientService).validateLedger(expectedForIntValue); + verify(clientService).validateLedger(expectedForDoubleValue); + verify(clientService).validateLedger(expectedForDoubleIntegerValue); + verify(clientService).validateLedger(expectedForBigDecimalValue); + } + + @Test + public void + validateRecord_StringValueGiven_ShouldCallClientServiceValidateLedgerWithRecordPrefix() { + // Arrange + String stringValue = "\"val\""; + String expectedForStringValue = + PREFIX_RECORD + ANY_TABLE + ASSET_ID_SEPARATOR + ANY_COLUMN + ASSET_ID_SEPARATOR + "val"; + String intValue = "1"; + String expectedForIntValue = + PREFIX_RECORD + ANY_TABLE + ASSET_ID_SEPARATOR + ANY_COLUMN + ASSET_ID_SEPARATOR + intValue; + String doubleValue = "1.23"; + String expectedForDoubleValue = + PREFIX_RECORD + + ANY_TABLE + + ASSET_ID_SEPARATOR + + ANY_COLUMN + + ASSET_ID_SEPARATOR + + doubleValue; + String doubleIntegerValue = "2.0"; + String expectedForDoubleIntegerValue = + PREFIX_RECORD + ANY_TABLE + ASSET_ID_SEPARATOR + ANY_COLUMN + ASSET_ID_SEPARATOR + "2"; + String bigDecimalValue = "1.2345678901234567890123456789"; + String expectedForBigDecimalValue = + PREFIX_RECORD + + ANY_TABLE + + ASSET_ID_SEPARATOR + + ANY_COLUMN + + ASSET_ID_SEPARATOR + + new BigDecimal(bigDecimalValue).doubleValue(); + + // Act + service.validateRecord(ANY_TABLE, ANY_COLUMN, stringValue); + service.validateRecord(ANY_TABLE, ANY_COLUMN, stringValue, ANY_START_AGE, ANY_END_AGE); + service.validateRecord(ANY_TABLE, ANY_COLUMN, intValue); + service.validateRecord(ANY_TABLE, ANY_COLUMN, doubleValue); + service.validateRecord(ANY_TABLE, ANY_COLUMN, doubleIntegerValue); + service.validateRecord(ANY_TABLE, ANY_COLUMN, bigDecimalValue); + + // Assert + verify(clientService).validateLedger(expectedForStringValue); + verify(clientService).validateLedger(expectedForStringValue, ANY_START_AGE, ANY_END_AGE); + verify(clientService).validateLedger(expectedForIntValue); + verify(clientService).validateLedger(expectedForDoubleValue); + verify(clientService).validateLedger(expectedForDoubleIntegerValue); + verify(clientService).validateLedger(expectedForBigDecimalValue); + } + + @Test + public void + validateIndexRecord_JsonValueGiven_ShouldCallClientServiceValidateLedgerWithIndexPrefix() { + // Arrange + String stringValue = "val"; + String expectedForStringValue = + PREFIX_INDEX + + ANY_TABLE + + ASSET_ID_SEPARATOR + + ANY_COLUMN + + ASSET_ID_SEPARATOR + + stringValue; + int intValue = 1; + String expectedForIntValue = + PREFIX_INDEX + ANY_TABLE + ASSET_ID_SEPARATOR + ANY_COLUMN + ASSET_ID_SEPARATOR + intValue; + double doubleValue = 1.23; + String expectedForDoubleValue = + PREFIX_INDEX + + ANY_TABLE + + ASSET_ID_SEPARATOR + + ANY_COLUMN + + ASSET_ID_SEPARATOR + + doubleValue; + double doubleIntegerValue = 2.0; + String expectedForDoubleIntegerValue = + PREFIX_INDEX + ANY_TABLE + ASSET_ID_SEPARATOR + ANY_COLUMN + ASSET_ID_SEPARATOR + "2"; + BigDecimal bigDecimalValue = new BigDecimal("1.2345678901234567890123456789"); + String expectedForBigDecimalValue = + PREFIX_INDEX + + ANY_TABLE + + ASSET_ID_SEPARATOR + + ANY_COLUMN + + ASSET_ID_SEPARATOR + + bigDecimalValue.doubleValue(); + + // Act + service.validateIndexRecord(ANY_TABLE, ANY_COLUMN, Json.createValue(stringValue)); + service.validateIndexRecord( + ANY_TABLE, ANY_COLUMN, Json.createValue(stringValue), ANY_START_AGE, ANY_END_AGE); + service.validateIndexRecord(ANY_TABLE, ANY_COLUMN, Json.createValue(intValue)); + service.validateIndexRecord(ANY_TABLE, ANY_COLUMN, Json.createValue(doubleValue)); + service.validateIndexRecord(ANY_TABLE, ANY_COLUMN, Json.createValue(doubleIntegerValue)); + service.validateIndexRecord(ANY_TABLE, ANY_COLUMN, Json.createValue(bigDecimalValue)); + + // Assert + verify(clientService).validateLedger(expectedForStringValue); + verify(clientService).validateLedger(expectedForStringValue, ANY_START_AGE, ANY_END_AGE); + verify(clientService).validateLedger(expectedForIntValue); + verify(clientService).validateLedger(expectedForDoubleValue); + verify(clientService).validateLedger(expectedForDoubleIntegerValue); + verify(clientService).validateLedger(expectedForBigDecimalValue); + } + + @Test + public void + validateIndexRecord_ValueNodeGiven_ShouldCallClientServiceValidateLedgerWithIndexPrefix() { + // Arrange + String stringValue = "val"; + String expectedForStringValue = + PREFIX_INDEX + + ANY_TABLE + + ASSET_ID_SEPARATOR + + ANY_COLUMN + + ASSET_ID_SEPARATOR + + stringValue; + int intValue = 1; + String expectedForIntValue = + PREFIX_INDEX + ANY_TABLE + ASSET_ID_SEPARATOR + ANY_COLUMN + ASSET_ID_SEPARATOR + intValue; + double doubleValue = 1.23; + String expectedForDoubleValue = + PREFIX_INDEX + + ANY_TABLE + + ASSET_ID_SEPARATOR + + ANY_COLUMN + + ASSET_ID_SEPARATOR + + doubleValue; + double doubleIntegerValue = 2.0; + String expectedForDoubleIntegerValue = + PREFIX_INDEX + ANY_TABLE + ASSET_ID_SEPARATOR + ANY_COLUMN + ASSET_ID_SEPARATOR + "2"; + BigDecimal bigDecimalValue = new BigDecimal("1.2345678901234567890123456789"); + String expectedForBigDecimalValue = + PREFIX_INDEX + + ANY_TABLE + + ASSET_ID_SEPARATOR + + ANY_COLUMN + + ASSET_ID_SEPARATOR + + bigDecimalValue.doubleValue(); + + // Act + service.validateIndexRecord(ANY_TABLE, ANY_COLUMN, TextNode.valueOf(stringValue)); + service.validateIndexRecord( + ANY_TABLE, ANY_COLUMN, TextNode.valueOf(stringValue), ANY_START_AGE, ANY_END_AGE); + service.validateIndexRecord(ANY_TABLE, ANY_COLUMN, IntNode.valueOf(intValue)); + service.validateIndexRecord(ANY_TABLE, ANY_COLUMN, DoubleNode.valueOf(doubleValue)); + service.validateIndexRecord(ANY_TABLE, ANY_COLUMN, DoubleNode.valueOf(doubleIntegerValue)); + service.validateIndexRecord(ANY_TABLE, ANY_COLUMN, DecimalNode.valueOf(bigDecimalValue)); + + // Assert + verify(clientService).validateLedger(expectedForStringValue); + verify(clientService).validateLedger(expectedForStringValue, ANY_START_AGE, ANY_END_AGE); + verify(clientService).validateLedger(expectedForIntValue); + verify(clientService).validateLedger(expectedForDoubleValue); + verify(clientService).validateLedger(expectedForDoubleIntegerValue); + verify(clientService).validateLedger(expectedForBigDecimalValue); + } + + @Test + public void + validateIndexRecord_StringValueGiven_ShouldCallClientServiceValidateLedgerWithIndexPrefix() { + // Arrange + String stringValue = "\"val\""; + String expectedForStringValue = + PREFIX_INDEX + ANY_TABLE + ASSET_ID_SEPARATOR + ANY_COLUMN + ASSET_ID_SEPARATOR + "val"; + String intValue = "1"; + String expectedForIntValue = + PREFIX_INDEX + ANY_TABLE + ASSET_ID_SEPARATOR + ANY_COLUMN + ASSET_ID_SEPARATOR + intValue; + String doubleValue = "1.23"; + String expectedForDoubleValue = + PREFIX_INDEX + + ANY_TABLE + + ASSET_ID_SEPARATOR + + ANY_COLUMN + + ASSET_ID_SEPARATOR + + doubleValue; + String doubleIntegerValue = "2.0"; + String expectedForDoubleIntegerValue = + PREFIX_INDEX + ANY_TABLE + ASSET_ID_SEPARATOR + ANY_COLUMN + ASSET_ID_SEPARATOR + "2"; + String bigDecimalValue = "1.2345678901234567890123456789"; + String expectedForBigDecimalValue = + PREFIX_INDEX + + ANY_TABLE + + ASSET_ID_SEPARATOR + + ANY_COLUMN + + ASSET_ID_SEPARATOR + + new BigDecimal(bigDecimalValue).doubleValue(); + + // Act + service.validateIndexRecord(ANY_TABLE, ANY_COLUMN, stringValue); + service.validateIndexRecord(ANY_TABLE, ANY_COLUMN, stringValue, ANY_START_AGE, ANY_END_AGE); + service.validateIndexRecord(ANY_TABLE, ANY_COLUMN, intValue); + service.validateIndexRecord(ANY_TABLE, ANY_COLUMN, doubleValue); + service.validateIndexRecord(ANY_TABLE, ANY_COLUMN, doubleIntegerValue); + service.validateIndexRecord(ANY_TABLE, ANY_COLUMN, bigDecimalValue); + + // Assert + verify(clientService).validateLedger(expectedForStringValue); + verify(clientService).validateLedger(expectedForStringValue, ANY_START_AGE, ANY_END_AGE); + verify(clientService).validateLedger(expectedForIntValue); + verify(clientService).validateLedger(expectedForDoubleValue); + verify(clientService).validateLedger(expectedForDoubleIntegerValue); + verify(clientService).validateLedger(expectedForBigDecimalValue); + } +} diff --git a/table-store/src/test/java/com/scalar/dl/tablestore/client/tool/ContractsRegistrationTest.java b/table-store/src/test/java/com/scalar/dl/tablestore/client/tool/ContractsRegistrationTest.java new file mode 100644 index 00000000..3dc29775 --- /dev/null +++ b/table-store/src/test/java/com/scalar/dl/tablestore/client/tool/ContractsRegistrationTest.java @@ -0,0 +1,123 @@ +package com.scalar.dl.tablestore.client.tool; + +import static com.scalar.dl.client.tool.CommandLineTestUtils.createDefaultClientPropertiesFile; +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.mockito.Mockito.*; + +import com.scalar.dl.client.config.GatewayClientConfig; +import com.scalar.dl.client.exception.ClientException; +import com.scalar.dl.client.tool.CommandLineTestUtils; +import com.scalar.dl.ledger.service.StatusCode; +import com.scalar.dl.tablestore.client.service.ClientService; +import com.scalar.dl.tablestore.client.service.ClientServiceFactory; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.PrintStream; +import java.io.UnsupportedEncodingException; +import java.nio.file.Path; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import picocli.CommandLine; + +public class ContractsRegistrationTest { + private CommandLine commandLine; + private final ByteArrayOutputStream outputStreamCaptor = new ByteArrayOutputStream(); + + @BeforeEach + void setup() throws Exception { + commandLine = new CommandLine(new ContractsRegistration()); + + // To verify the output to stdout, e.g., System.out.println(...). + System.setOut(new PrintStream(outputStreamCaptor, true, UTF_8.name())); + } + + @Nested + @DisplayName("#call()") + class call { + @Nested + @DisplayName("where register-contracts succeeds via ClientService") + class whereRegisterContractsSucceedsViaClientService { + @Test + @DisplayName("returns 0 as exit code") + void returns0AsExitCode() { + // Arrange + String[] args = + new String[] { + "--properties=PROPERTIES_FILE", + }; + ContractsRegistration command = parseArgs(args); + ClientServiceFactory factoryMock = mock(ClientServiceFactory.class); + ClientService serviceMock = mock(ClientService.class); + + // Act + int exitCode = command.call(factoryMock, serviceMock); + + // Assert + assertThat(exitCode).isEqualTo(0); + verify(serviceMock).registerContracts(); + verify(factoryMock).close(); + } + } + + @Nested + @DisplayName("where register-contracts fails via ClientService") + class whereRegisterContractsFailsViaClientService { + @Test + @DisplayName("returns 1 as exit code") + void returns1AsExitCode() throws UnsupportedEncodingException { + // Arrange + String[] args = + new String[] { + "--properties=PROPERTIES_FILE", + }; + ContractsRegistration command = parseArgs(args); + ClientServiceFactory factoryMock = mock(ClientServiceFactory.class); + ClientService serviceMock = mock(ClientService.class); + doThrow(new ClientException("Some error", StatusCode.RUNTIME_ERROR)) + .when(serviceMock) + .registerContracts(); + + // Act + int exitCode = command.call(factoryMock, serviceMock); + + // Assert + assertThat(exitCode).isEqualTo(1); + verify(serviceMock).registerContracts(); + verify(factoryMock).close(); + assertThat(outputStreamCaptor.toString(UTF_8.name())).contains("Some error"); + } + } + + @Test + @DisplayName("with --use-gateway option returns 0 as exit code") + void withGatewayOptionReturns0AsExitCode(@TempDir Path tempDir) throws Exception { + // Arrange + File propertiesFile = createDefaultClientPropertiesFile(tempDir, "client.properties"); + String[] args = + new String[] { + "--properties=" + propertiesFile.getAbsolutePath(), "--use-gateway", + }; + ContractsRegistration command = parseArgs(args); + ClientServiceFactory factoryMock = mock(ClientServiceFactory.class); + ClientService serviceMock = mock(ClientService.class); + when(factoryMock.create(any(GatewayClientConfig.class), anyBoolean())) + .thenReturn(serviceMock); + + // Act + int exitCode = command.call(factoryMock); + + // Assert + assertThat(exitCode).isEqualTo(0); + verify(serviceMock).registerContracts(); + verify(factoryMock).close(); + } + } + + private ContractsRegistration parseArgs(String[] args) { + return CommandLineTestUtils.parseArgs(commandLine, ContractsRegistration.class, args); + } +} diff --git a/table-store/src/test/java/com/scalar/dl/tablestore/client/tool/LedgerValidationTest.java b/table-store/src/test/java/com/scalar/dl/tablestore/client/tool/LedgerValidationTest.java new file mode 100644 index 00000000..35919847 --- /dev/null +++ b/table-store/src/test/java/com/scalar/dl/tablestore/client/tool/LedgerValidationTest.java @@ -0,0 +1,184 @@ +package com.scalar.dl.tablestore.client.tool; + +import static com.scalar.dl.client.tool.CommandLineTestUtils.createDefaultClientPropertiesFile; +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.mockito.Mockito.*; + +import com.scalar.dl.client.config.ClientConfig; +import com.scalar.dl.client.config.GatewayClientConfig; +import com.scalar.dl.client.exception.ClientException; +import com.scalar.dl.client.tool.CommandLineTestUtils; +import com.scalar.dl.ledger.model.LedgerValidationResult; +import com.scalar.dl.ledger.service.StatusCode; +import com.scalar.dl.tablestore.client.service.ClientService; +import com.scalar.dl.tablestore.client.service.ClientServiceFactory; +import java.io.File; +import java.nio.file.Path; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import picocli.CommandLine; + +public class LedgerValidationTest { + private CommandLine commandLine; + + @BeforeEach + void setup() { + commandLine = new CommandLine(new LedgerValidation()); + } + + @Nested + @DisplayName("#call()") + class call { + @Nested + @DisplayName("where table-name is set") + class whereTableNameIsSet { + @Test + @DisplayName("validates table schema when no key is specified and returns 0 as exit code") + void validatesTableSchemaAndReturns0AsExitCode() { + // Arrange + String[] args = + new String[] { + "--properties=PROPERTIES_FILE", + "--table-name=test_table", + "--start-age=0", + "--end-age=10" + }; + LedgerValidation command = parseArgs(args); + ClientServiceFactory factoryMock = mock(ClientServiceFactory.class); + ClientService serviceMock = mock(ClientService.class); + + LedgerValidationResult result = new LedgerValidationResult(StatusCode.OK, null, null); + when(serviceMock.validateTableSchema("test_table", 0, 10)).thenReturn(result); + + // Act + int exitCode = command.call(factoryMock, serviceMock); + + // Assert + assertThat(exitCode).isEqualTo(0); + verify(serviceMock).validateTableSchema("test_table", 0, 10); + verify(factoryMock).close(); + } + + @Test + @DisplayName("validates record with primary key and returns 0 as exit code") + void validatesRecordWithPrimaryKeyAndReturns0AsExitCode() { + // Arrange + String[] args = + new String[] { + "--properties=PROPERTIES_FILE", + "--table-name=test_table", + "--primary-key-column-name=id", + "--column-value=\"123\"", + "--start-age=0", + "--end-age=10" + }; + LedgerValidation command = parseArgs(args); + ClientServiceFactory factoryMock = mock(ClientServiceFactory.class); + ClientService serviceMock = mock(ClientService.class); + + LedgerValidationResult result = new LedgerValidationResult(StatusCode.OK, null, null); + when(serviceMock.validateRecord("test_table", "id", "\"123\"", 0, 10)).thenReturn(result); + + // Act + int exitCode = command.call(factoryMock, serviceMock); + + // Assert + assertThat(exitCode).isEqualTo(0); + verify(serviceMock).validateRecord("test_table", "id", "\"123\"", 0, 10); + verify(factoryMock).close(); + } + + @Test + @DisplayName("validates index record and returns 0 as exit code") + void validatesIndexRecordAndReturns0AsExitCode() { + // Arrange + String[] args = + new String[] { + "--properties=PROPERTIES_FILE", + "--table-name=test_table", + "--index-key-column-name=email", + "--column-value=\"test@example.com\"", + "--start-age=0", + "--end-age=10" + }; + LedgerValidation command = parseArgs(args); + ClientServiceFactory factoryMock = mock(ClientServiceFactory.class); + ClientService serviceMock = mock(ClientService.class); + + LedgerValidationResult result = new LedgerValidationResult(StatusCode.OK, null, null); + when(serviceMock.validateIndexRecord("test_table", "email", "\"test@example.com\"", 0, 10)) + .thenReturn(result); + + // Act + int exitCode = command.call(factoryMock, serviceMock); + + // Assert + assertThat(exitCode).isEqualTo(0); + verify(serviceMock) + .validateIndexRecord("test_table", "email", "\"test@example.com\"", 0, 10); + verify(factoryMock).close(); + } + } + + @Nested + @DisplayName("where useGateway option is true") + class whereUseGatewayOptionIsTrue { + @Test + @DisplayName("create ClientService with GatewayClientConfig") + public void createClientServiceWithGatewayClientConfig(@TempDir Path tempDir) + throws Exception { + // Arrange + File file = createDefaultClientPropertiesFile(tempDir, "client.props"); + String[] args = + new String[] { + "--properties=" + file.getAbsolutePath(), "--table-name=test_table", "--use-gateway" + }; + LedgerValidation command = parseArgs(args); + ClientServiceFactory factory = mock(ClientServiceFactory.class); + ClientService serviceMock = mock(ClientService.class); + + LedgerValidationResult result = new LedgerValidationResult(StatusCode.OK, null, null); + when(serviceMock.validateTableSchema(anyString(), anyInt(), anyInt())).thenReturn(result); + when(factory.create(any(GatewayClientConfig.class), anyBoolean())).thenReturn(serviceMock); + + // Act + command.call(factory); + + // Verify + verify(factory).create(any(GatewayClientConfig.class), eq(false)); + verify(factory, never()).create(any(ClientConfig.class), anyBoolean()); + } + } + + @Nested + @DisplayName("where ClientService throws ClientException") + class whereClientExceptionIsThrownByClientService { + @Test + @DisplayName("returns 1 as exit code") + void returns1AsExitCode() { + // Arrange + String[] args = new String[] {"--properties=PROPERTIES_FILE", "--table-name=test_table"}; + LedgerValidation command = parseArgs(args); + ClientServiceFactory factoryMock = mock(ClientServiceFactory.class); + ClientService serviceMock = mock(ClientService.class); + + when(serviceMock.validateTableSchema(anyString(), anyInt(), anyInt())) + .thenThrow(new ClientException("Validation failed", StatusCode.RUNTIME_ERROR)); + + // Act + int exitCode = command.call(factoryMock, serviceMock); + + // Assert + assertThat(exitCode).isEqualTo(1); + verify(factoryMock).close(); + } + } + } + + private LedgerValidation parseArgs(String[] args) { + return CommandLineTestUtils.parseArgs(commandLine, LedgerValidation.class, args); + } +} diff --git a/table-store/src/test/java/com/scalar/dl/tablestore/client/tool/StatementExecutionTest.java b/table-store/src/test/java/com/scalar/dl/tablestore/client/tool/StatementExecutionTest.java new file mode 100644 index 00000000..bec58363 --- /dev/null +++ b/table-store/src/test/java/com/scalar/dl/tablestore/client/tool/StatementExecutionTest.java @@ -0,0 +1,180 @@ +package com.scalar.dl.tablestore.client.tool; + +import static com.scalar.dl.client.tool.CommandLineTestUtils.createDefaultClientPropertiesFile; +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.mockito.Mockito.*; + +import com.scalar.dl.client.config.ClientConfig; +import com.scalar.dl.client.config.GatewayClientConfig; +import com.scalar.dl.client.exception.ClientException; +import com.scalar.dl.client.tool.CommandLineTestUtils; +import com.scalar.dl.ledger.model.ContractExecutionResult; +import com.scalar.dl.ledger.service.StatusCode; +import com.scalar.dl.tablestore.client.model.StatementExecutionResult; +import com.scalar.dl.tablestore.client.service.ClientService; +import com.scalar.dl.tablestore.client.service.ClientServiceFactory; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.PrintStream; +import java.io.UnsupportedEncodingException; +import java.nio.file.Path; +import java.util.Collections; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import picocli.CommandLine; + +public class StatementExecutionTest { + private CommandLine commandLine; + private final ByteArrayOutputStream outputStreamCaptor = new ByteArrayOutputStream(); + + @BeforeEach + void setup() throws Exception { + commandLine = new CommandLine(new StatementExecution()); + + // To verify the output to stdout, e.g., System.out.println(...). + System.setOut(new PrintStream(outputStreamCaptor, true, UTF_8.name())); + } + + @Nested + @DisplayName("#call()") + class call { + @Nested + @DisplayName("where statement execution succeeds") + class whereStatementExecutionSucceeds { + @Test + @DisplayName("returns 0 as exit code with result") + void returns0AsExitCodeWithResult() throws Exception { + // Arrange + String[] args = + new String[] { + "--properties=PROPERTIES_FILE", + "--statement=INSERT INTO test_table VALUES {'id': '123', 'name': 'test'}" + }; + StatementExecution command = parseArgs(args); + ClientServiceFactory factoryMock = mock(ClientServiceFactory.class); + ClientService serviceMock = mock(ClientService.class); + + String resultJson = "{\"status\":\"success\"}"; + ContractExecutionResult contractResult = + new ContractExecutionResult( + resultJson, null, Collections.emptyList(), Collections.emptyList()); + StatementExecutionResult result = new StatementExecutionResult(contractResult); + when(serviceMock.executeStatement(anyString())).thenReturn(result); + + // Act + int exitCode = command.call(factoryMock, serviceMock); + + // Assert + assertThat(exitCode).isEqualTo(0); + verify(serviceMock) + .executeStatement("INSERT INTO test_table VALUES {'id': '123', 'name': 'test'}"); + verify(factoryMock).close(); + + String stdout = outputStreamCaptor.toString(UTF_8.name()); + assertThat(stdout).contains("Result:"); + assertThat(stdout).contains("\"status\" : \"success\""); + } + + @Test + @DisplayName("returns 0 as exit code without result") + void returns0AsExitCodeWithoutResult() throws Exception { + // Arrange + String[] args = + new String[] { + "--properties=PROPERTIES_FILE", + "--statement=CREATE TABLE test_table (id STRING PRIMARY KEY, name STRING)" + }; + StatementExecution command = parseArgs(args); + ClientServiceFactory factoryMock = mock(ClientServiceFactory.class); + ClientService serviceMock = mock(ClientService.class); + + ContractExecutionResult contractResult = + new ContractExecutionResult( + null, null, Collections.emptyList(), Collections.emptyList()); + StatementExecutionResult result = new StatementExecutionResult(contractResult); + when(serviceMock.executeStatement(anyString())).thenReturn(result); + + // Act + int exitCode = command.call(factoryMock, serviceMock); + + // Assert + assertThat(exitCode).isEqualTo(0); + verify(serviceMock) + .executeStatement("CREATE TABLE test_table (id STRING PRIMARY KEY, name STRING)"); + verify(factoryMock).close(); + + String stdout = outputStreamCaptor.toString(UTF_8.name()); + assertThat(stdout).doesNotContain("Result:"); + } + } + + @Nested + @DisplayName("where useGateway option is true") + class whereUseGatewayOptionIsTrue { + @Test + @DisplayName("create ClientService with GatewayClientConfig") + public void createClientServiceWithGatewayClientConfig(@TempDir Path tempDir) + throws Exception { + // Arrange + File file = createDefaultClientPropertiesFile(tempDir, "client.props"); + String[] args = + new String[] { + "--properties=" + file.getAbsolutePath(), + "--statement=SELECT * FROM test_table", + "--use-gateway" + }; + StatementExecution command = parseArgs(args); + ClientServiceFactory factory = mock(ClientServiceFactory.class); + ClientService serviceMock = mock(ClientService.class); + + ContractExecutionResult contractResult = + new ContractExecutionResult( + null, null, java.util.Collections.emptyList(), java.util.Collections.emptyList()); + StatementExecutionResult result = new StatementExecutionResult(contractResult); + when(serviceMock.executeStatement(anyString())).thenReturn(result); + when(factory.create(any(GatewayClientConfig.class), anyBoolean())).thenReturn(serviceMock); + + // Act + command.call(factory); + + // Verify + verify(factory).create(any(GatewayClientConfig.class), eq(false)); + verify(factory, never()).create(any(ClientConfig.class), anyBoolean()); + } + } + + @Nested + @DisplayName("where ClientService throws ClientException") + class whereClientExceptionIsThrownByClientService { + @Test + @DisplayName("returns 1 as exit code") + void returns1AsExitCode() throws UnsupportedEncodingException { + // Arrange + String[] args = + new String[] {"--properties=PROPERTIES_FILE", "--statement=INVALID STATEMENT"}; + StatementExecution command = parseArgs(args); + ClientServiceFactory factoryMock = mock(ClientServiceFactory.class); + ClientService serviceMock = mock(ClientService.class); + + when(serviceMock.executeStatement(anyString())) + .thenThrow(new ClientException("Invalid statement", StatusCode.RUNTIME_ERROR)); + + // Act + int exitCode = command.call(factoryMock, serviceMock); + + // Assert + assertThat(exitCode).isEqualTo(1); + verify(factoryMock).close(); + assertThat(outputStreamCaptor.toString(UTF_8.name())).contains("Invalid statement"); + } + } + } + + private StatementExecution parseArgs(String[] args) { + return CommandLineTestUtils.parseArgs(commandLine, StatementExecution.class, args); + } +} diff --git a/table-store/src/test/java/com/scalar/dl/tablestore/client/tool/TableStoreCommandLineTest.java b/table-store/src/test/java/com/scalar/dl/tablestore/client/tool/TableStoreCommandLineTest.java new file mode 100644 index 00000000..05cfc707 --- /dev/null +++ b/table-store/src/test/java/com/scalar/dl/tablestore/client/tool/TableStoreCommandLineTest.java @@ -0,0 +1,185 @@ +package com.scalar.dl.tablestore.client.tool; + +import static com.scalar.dl.tablestore.client.tool.TableStoreCommandLine.setupSections; +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; + +import com.scalar.dl.client.tool.CertificateRegistration; +import com.scalar.dl.client.tool.ContractsListing; +import com.scalar.dl.client.tool.SecretRegistration; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import picocli.CommandLine; +import picocli.CommandLine.Command; +import picocli.CommandLine.ParseResult; +import picocli.CommandLine.UnmatchedArgumentException; + +public class TableStoreCommandLineTest { + private CommandLine commandLine; + + @BeforeEach + void setup() { + commandLine = new CommandLine(new TableStoreCommandLine()); + setupSections(commandLine); + } + + @Nested + @DisplayName("#getUsageCommand()") + class getUsageCommand { + @Test + @DisplayName("displays the grouped subcommands") + void displaysGroupedSubcommands() { + // Act + String actual = commandLine.getUsageMessage(); + + // Assert + String expected = + String.join( + System.lineSeparator(), + "Usage: scalardl-table-store [COMMAND]", + "These are ScalarDL Table Store commands used in various situations:", + "", + "register identity information", + " register-cert Register a specified certificate.", + " register-secret Register a specified secret.", + "", + "register contracts for the table store", + " register-contracts Register all necessary contracts.", + "", + "list the registered contracts", + " list-contracts List registered contracts.", + "", + "execute a statement", + " execute-statement Execute a specified statement.", + "", + "validate ledger", + " validate-ledger Validate a specified asset in the ledger.", + ""); + assertThat(actual).isEqualTo(expected); + } + } + + @Nested + @DisplayName("@Command annotation") + @SuppressWarnings("ClassCanBeStatic") + class CommandAnnotation { + private Command command; + + @BeforeEach + void setup() { + command = TableStoreCommandLine.class.getAnnotation(Command.class); + } + + @Test + @DisplayName("member values are properly set") + void memberValuesAreProperlySet() { + assertThat(command.name()).isEqualTo("scalardl-table-store"); + assertThat(command.subcommands()) + .isEqualTo( + new Class[] { + CertificateRegistration.class, + ContractsListing.class, + ContractsRegistration.class, + CommandLine.HelpCommand.class, + LedgerValidation.class, + SecretRegistration.class, + StatementExecution.class, + }); + } + } + + @Nested + @DisplayName("#parseArgs(...)") + class parseArgs { + @Nested + @DisplayName("without arguments") + class withoutArguments { + @Test + public void parseCommandSucceeds() { + // Arrange + String[] args = {}; + + // Act + ParseResult parseResult = commandLine.parseArgs(args); + List parsed = parseResult.asCommandLineList(); + + // Assert + // Verify that the argument contains only the top-level command. + assertThat(parsed.size()).isEqualTo(1); + + // Verify that the top-level command is "scalardl-table-store". + assertThat(parsed.get(0).getCommand().getClass()).isEqualTo(TableStoreCommandLine.class); + } + } + + @Nested + @DisplayName("with invalid subcommand that is not configured") + class withInvalidSubcommand { + @Test + void throwsUnmatchedArgumentException() { + // Arrange + String[] args = new String[] {"invalid-subcommand"}; + + // Act & Assert + assertThatThrownBy(() -> commandLine.parseArgs(args)) + .isInstanceOf(UnmatchedArgumentException.class) + .hasMessageContaining("Unmatched argument at index 0: 'invalid-subcommand'"); + } + } + + @Nested + @DisplayName("with valid subcommands") + class withValidSubcommands { + @Test + @DisplayName("execute-statement subcommand is parsed correctly") + void executeStatementSubcommandIsParsedCorrectly() { + // Arrange + String[] args = new String[] {"execute-statement", "--help"}; + + // Act + ParseResult parseResult = commandLine.parseArgs(args); + List parsed = parseResult.asCommandLineList(); + + // Assert + assertThat(parsed.size()).isEqualTo(2); + assertThat(parsed.get(0).getCommand().getClass()).isEqualTo(TableStoreCommandLine.class); + assertThat(parsed.get(1).getCommand().getClass()).isEqualTo(StatementExecution.class); + } + + @Test + @DisplayName("register-contracts subcommand is parsed correctly") + void registerContractsSubcommandIsParsedCorrectly() { + // Arrange + String[] args = new String[] {"register-contracts", "--help"}; + + // Act + ParseResult parseResult = commandLine.parseArgs(args); + List parsed = parseResult.asCommandLineList(); + + // Assert + assertThat(parsed.size()).isEqualTo(2); + assertThat(parsed.get(0).getCommand().getClass()).isEqualTo(TableStoreCommandLine.class); + assertThat(parsed.get(1).getCommand().getClass()).isEqualTo(ContractsRegistration.class); + } + + @Test + @DisplayName("validate-ledger subcommand is parsed correctly") + void validateLedgerSubcommandIsParsedCorrectly() { + // Arrange + String[] args = new String[] {"validate-ledger", "--help"}; + + // Act + ParseResult parseResult = commandLine.parseArgs(args); + List parsed = parseResult.asCommandLineList(); + + // Assert + assertThat(parsed.size()).isEqualTo(2); + assertThat(parsed.get(0).getCommand().getClass()).isEqualTo(TableStoreCommandLine.class); + assertThat(parsed.get(1).getCommand().getClass()).isEqualTo(LedgerValidation.class); + } + } + } +}