diff --git a/core/src/integration-test/java/com/scalar/db/storage/jdbc/JdbcDatabaseVirtualTablesIntegrationTest.java b/core/src/integration-test/java/com/scalar/db/storage/jdbc/JdbcDatabaseVirtualTablesIntegrationTest.java new file mode 100644 index 0000000000..e0258820d9 --- /dev/null +++ b/core/src/integration-test/java/com/scalar/db/storage/jdbc/JdbcDatabaseVirtualTablesIntegrationTest.java @@ -0,0 +1,13 @@ +package com.scalar.db.storage.jdbc; + +import com.scalar.db.api.DistributedStorageVirtualTablesIntegrationTestBase; +import java.util.Properties; + +public class JdbcDatabaseVirtualTablesIntegrationTest + extends DistributedStorageVirtualTablesIntegrationTestBase { + + @Override + protected Properties getProperties(String testName) { + return JdbcEnv.getProperties(testName); + } +} diff --git a/core/src/main/java/com/scalar/db/api/DistributedStorageAdmin.java b/core/src/main/java/com/scalar/db/api/DistributedStorageAdmin.java index ce76905fa7..bcc16eb30c 100644 --- a/core/src/main/java/com/scalar/db/api/DistributedStorageAdmin.java +++ b/core/src/main/java/com/scalar/db/api/DistributedStorageAdmin.java @@ -1,6 +1,9 @@ package com.scalar.db.api; import com.scalar.db.exception.storage.ExecutionException; +import java.util.Collections; +import java.util.Map; +import java.util.Optional; /** * An administrative interface for distributed storage implementations. The user can execute @@ -50,6 +53,203 @@ public interface DistributedStorageAdmin extends Admin, AutoCloseable { */ StorageInfo getStorageInfo(String namespace) throws ExecutionException; + /** + * Creates a virtual table that exposes a logical join of two source tables on their primary key. + * This feature is intended for scenarios where related data is stored in separate tables but + * needs to be accessed or queried as a single logical entity. + * + *

Semantics: + * + *

+ * + *

Note: This feature is primarily for internal use. Breaking changes can and will be + * introduced to it. Users should not depend on it. + * + * @param namespace the namespace of the virtual table to create + * @param table the name of the virtual table to create + * @param leftSourceNamespace the namespace of the left source table + * @param leftSourceTable the name of the left source table + * @param rightSourceNamespace the namespace of the right source table + * @param rightSourceTable the name of the right source table + * @param joinType the type of join to perform between the two source tables + * @param options additional options for creating the virtual table + * @throws ExecutionException if the operation fails + * @throws IllegalArgumentException if preconditions are not met (schema mismatch, name conflicts, + * unsupported atomicity unit, etc.) + */ + void createVirtualTable( + String namespace, + String table, + String leftSourceNamespace, + String leftSourceTable, + String rightSourceNamespace, + String rightSourceTable, + VirtualTableJoinType joinType, + Map options) + throws ExecutionException; + + /** + * Creates a virtual table that exposes a logical join of two source tables on their primary key. + * + *

See {@link #createVirtualTable(String, String, String, String, String, String, + * VirtualTableJoinType, Map)} for semantics. + * + *

Note: This feature is primarily for internal use. Breaking changes can and will be + * introduced to it. Users should not depend on it. + * + * @param namespace the namespace of the virtual table to create + * @param table the name of the virtual table to create + * @param leftSourceNamespace the namespace of the left source table + * @param leftSourceTable the name of the left source table + * @param rightSourceNamespace the namespace of the right source table + * @param rightSourceTable the name of the right source table + * @param joinType the type of join to perform between the two source tables + * @param ifNotExists if set to true, the virtual table will be created only if it does not exist + * already. If set to false, it will throw an exception if it already exists + * @param options additional options for creating the virtual table + * @throws ExecutionException if the operation fails + * @throws IllegalArgumentException if preconditions are not met (schema mismatch, name conflicts, + * unsupported atomicity unit, etc.) + */ + default void createVirtualTable( + String namespace, + String table, + String leftSourceNamespace, + String leftSourceTable, + String rightSourceNamespace, + String rightSourceTable, + VirtualTableJoinType joinType, + boolean ifNotExists, + Map options) + throws ExecutionException { + if (ifNotExists && tableExists(namespace, table)) { + return; + } + createVirtualTable( + namespace, + table, + leftSourceNamespace, + leftSourceTable, + rightSourceNamespace, + rightSourceTable, + joinType, + options); + } + + /** + * Creates a virtual table that exposes a logical join of two source tables on their primary key. + * + *

See {@link #createVirtualTable(String, String, String, String, String, String, + * VirtualTableJoinType, Map)} for semantics. + * + *

Note: This feature is primarily for internal use. Breaking changes can and will be + * introduced to it. Users should not depend on it. + * + * @param namespace the namespace of the virtual table to create + * @param table the name of the virtual table to create + * @param leftSourceNamespace the namespace of the left source table + * @param leftSourceTable the name of the left source table + * @param rightSourceNamespace the namespace of the right source table + * @param rightSourceTable the name of the right source table + * @param joinType the type of join to perform between the two source tables + * @param ifNotExists if set to true, the virtual table will be created only if it does not exist + * already. If set to false, it will throw an exception if it already exists + * @throws ExecutionException if the operation fails + * @throws IllegalArgumentException if preconditions are not met (schema mismatch, name conflicts, + * unsupported atomicity unit, etc.) + */ + default void createVirtualTable( + String namespace, + String table, + String leftSourceNamespace, + String leftSourceTable, + String rightSourceNamespace, + String rightSourceTable, + VirtualTableJoinType joinType, + boolean ifNotExists) + throws ExecutionException { + createVirtualTable( + namespace, + table, + leftSourceNamespace, + leftSourceTable, + rightSourceNamespace, + rightSourceTable, + joinType, + ifNotExists, + Collections.emptyMap()); + } + + /** + * Creates a virtual table that exposes a logical join of two source tables on their primary key. + * + *

See {@link #createVirtualTable(String, String, String, String, String, String, + * VirtualTableJoinType, Map)} for semantics. + * + *

Note: This feature is primarily for internal use. Breaking changes can and will be + * introduced to it. Users should not depend on it. + * + * @param namespace the namespace of the virtual table to create + * @param table the name of the virtual table to create + * @param leftSourceNamespace the namespace of the left source table + * @param leftSourceTable the name of the left source table + * @param rightSourceNamespace the namespace of the right source table + * @param rightSourceTable the name of the right source table + * @param joinType the type of join to perform between the two source tables + * @throws ExecutionException if the operation fails + * @throws IllegalArgumentException if preconditions are not met (schema mismatch, name conflicts, + * unsupported atomicity unit, etc.) + */ + default void createVirtualTable( + String namespace, + String table, + String leftSourceNamespace, + String leftSourceTable, + String rightSourceNamespace, + String rightSourceTable, + VirtualTableJoinType joinType) + throws ExecutionException { + createVirtualTable( + namespace, + table, + leftSourceNamespace, + leftSourceTable, + rightSourceNamespace, + rightSourceTable, + joinType, + Collections.emptyMap()); + } + + /** + * Returns the virtual table information. + * + *

Note: This feature is primarily for internal use. Breaking changes can and will be + * introduced to it. Users should not depend on it. + * + * @param namespace the namespace + * @param table the table + * @return the virtual table information or {@code Optional.empty()} if the table is not a virtual + * table + * @throws ExecutionException if the operation fails + * @throws IllegalArgumentException if the table does not exist + */ + Optional getVirtualTableInfo(String namespace, String table) + throws ExecutionException; + /** Closes connections to the storage. */ @Override void close(); diff --git a/core/src/main/java/com/scalar/db/api/VirtualTableInfo.java b/core/src/main/java/com/scalar/db/api/VirtualTableInfo.java new file mode 100644 index 0000000000..51dedaf46a --- /dev/null +++ b/core/src/main/java/com/scalar/db/api/VirtualTableInfo.java @@ -0,0 +1,56 @@ +package com.scalar.db.api; + +/** + * Represents information about a virtual table, which is a view created by joining two source + * tables. + */ +public interface VirtualTableInfo { + /** + * Returns the namespace name of the virtual table. + * + * @return the namespace name of the virtual table + */ + String getNamespaceName(); + + /** + * Returns the table name of the virtual table. + * + * @return the table name of the virtual table + */ + String getTableName(); + + /** + * Returns the namespace name of the left source table. + * + * @return the namespace name of the left source table + */ + String getLeftSourceNamespaceName(); + + /** + * Returns the table name of the left source table. + * + * @return the table name of the left source table + */ + String getLeftSourceTableName(); + + /** + * Returns the namespace name of the right source table. + * + * @return the namespace name of the right source table + */ + String getRightSourceNamespaceName(); + + /** + * Returns the table name of the right source table. + * + * @return the table name of the right source table + */ + String getRightSourceTableName(); + + /** + * Returns the join type used to create this virtual table. + * + * @return the join type (INNER or LEFT_OUTER) + */ + VirtualTableJoinType getJoinType(); +} diff --git a/core/src/main/java/com/scalar/db/api/VirtualTableJoinType.java b/core/src/main/java/com/scalar/db/api/VirtualTableJoinType.java new file mode 100644 index 0000000000..489a124e9f --- /dev/null +++ b/core/src/main/java/com/scalar/db/api/VirtualTableJoinType.java @@ -0,0 +1,22 @@ +package com.scalar.db.api; + +/** + * The type of join to perform between two source tables of a virtual table. + * + *

This enum defines the types of joins that can be performed when creating a virtual table that + * combines data from two source tables. + */ +public enum VirtualTableJoinType { + /** + * An inner join returns only the rows where there is a match in both source tables based on their + * primary key. + */ + INNER, + + /** + * A left outer join returns all rows from the left source table and the matched rows from the + * right source table. If there is no match for a left row, the right-side columns appear as + * {@code NULL}. + */ + LEFT_OUTER +} diff --git a/core/src/main/java/com/scalar/db/common/CommonDistributedStorageAdmin.java b/core/src/main/java/com/scalar/db/common/CommonDistributedStorageAdmin.java index d75edd5d1a..aae71fa43a 100644 --- a/core/src/main/java/com/scalar/db/common/CommonDistributedStorageAdmin.java +++ b/core/src/main/java/com/scalar/db/common/CommonDistributedStorageAdmin.java @@ -3,11 +3,15 @@ import com.scalar.db.api.DistributedStorageAdmin; import com.scalar.db.api.StorageInfo; import com.scalar.db.api.TableMetadata; +import com.scalar.db.api.VirtualTableInfo; +import com.scalar.db.api.VirtualTableJoinType; import com.scalar.db.exception.storage.ExecutionException; import com.scalar.db.io.DataType; import com.scalar.db.util.ScalarDbUtils; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import java.util.HashSet; import java.util.Map; +import java.util.Optional; import java.util.Set; import javax.annotation.Nullable; import org.slf4j.Logger; @@ -476,6 +480,188 @@ public StorageInfo getStorageInfo(String namespace) throws ExecutionException { } } + @Override + public void createVirtualTable( + String namespace, + String table, + String leftSourceNamespace, + String leftSourceTable, + String rightSourceNamespace, + String rightSourceTable, + VirtualTableJoinType joinType, + Map options) + throws ExecutionException { + StorageInfo storageInfo = getStorageInfo(leftSourceNamespace); + switch (storageInfo.getMutationAtomicityUnit()) { + case STORAGE: + break; + case NAMESPACE: + if (!leftSourceNamespace.equals(rightSourceNamespace)) { + throw new IllegalArgumentException( + CoreError.VIRTUAL_TABLE_SOURCE_TABLES_OUTSIDE_OF_ATOMICITY_UNIT.buildMessage( + storageInfo.getStorageName(), + storageInfo.getMutationAtomicityUnit(), + ScalarDbUtils.getFullTableName(leftSourceNamespace, leftSourceTable), + ScalarDbUtils.getFullTableName(rightSourceNamespace, rightSourceTable))); + } + break; + default: + throw new UnsupportedOperationException( + CoreError.VIRTUAL_TABLE_NOT_SUPPORTED_IN_STORAGE.buildMessage( + storageInfo.getStorageName(), storageInfo.getMutationAtomicityUnit())); + } + + if (checkNamespace && !namespaceExists(namespace)) { + throw new IllegalArgumentException(CoreError.NAMESPACE_NOT_FOUND.buildMessage(namespace)); + } + + if (tableExists(namespace, table)) { + throw new IllegalArgumentException( + CoreError.TABLE_ALREADY_EXISTS.buildMessage( + ScalarDbUtils.getFullTableName(namespace, table))); + } + + TableMetadata leftSourceTableMetadata = getTableMetadata(leftSourceNamespace, leftSourceTable); + if (leftSourceTableMetadata == null) { + throw new IllegalArgumentException( + CoreError.TABLE_NOT_FOUND.buildMessage( + ScalarDbUtils.getFullTableName(leftSourceNamespace, leftSourceTable))); + } + + TableMetadata rightSourceTableMetadata = + getTableMetadata(rightSourceNamespace, rightSourceTable); + if (rightSourceTableMetadata == null) { + throw new IllegalArgumentException( + CoreError.TABLE_NOT_FOUND.buildMessage( + ScalarDbUtils.getFullTableName(rightSourceNamespace, rightSourceTable))); + } + + // Check that the partition key and the clustering key names match + if (!leftSourceTableMetadata + .getPartitionKeyNames() + .equals(rightSourceTableMetadata.getPartitionKeyNames())) { + throw new IllegalArgumentException( + CoreError.VIRTUAL_TABLE_SOURCE_TABLES_HAVE_DIFFERENT_PRIMARY_KEY.buildMessage( + ScalarDbUtils.getFullTableName(leftSourceNamespace, leftSourceTable), + ScalarDbUtils.getFullTableName(rightSourceNamespace, rightSourceTable))); + } + if (!leftSourceTableMetadata + .getClusteringKeyNames() + .equals(rightSourceTableMetadata.getClusteringKeyNames())) { + throw new IllegalArgumentException( + CoreError.VIRTUAL_TABLE_SOURCE_TABLES_HAVE_DIFFERENT_PRIMARY_KEY.buildMessage( + ScalarDbUtils.getFullTableName(leftSourceNamespace, leftSourceTable), + ScalarDbUtils.getFullTableName(rightSourceNamespace, rightSourceTable))); + } + + // Check that partition key data types match + for (String partitionKey : leftSourceTableMetadata.getPartitionKeyNames()) { + if (leftSourceTableMetadata.getColumnDataType(partitionKey) + != rightSourceTableMetadata.getColumnDataType(partitionKey)) { + throw new IllegalArgumentException( + CoreError.VIRTUAL_TABLE_SOURCE_TABLES_HAVE_DIFFERENT_PRIMARY_KEY_TYPES.buildMessage( + partitionKey, + ScalarDbUtils.getFullTableName(leftSourceNamespace, leftSourceTable), + ScalarDbUtils.getFullTableName(rightSourceNamespace, rightSourceTable))); + } + } + + // Check that clustering key data types and clustering orders match + for (String clusteringKey : leftSourceTableMetadata.getClusteringKeyNames()) { + if (leftSourceTableMetadata.getColumnDataType(clusteringKey) + != rightSourceTableMetadata.getColumnDataType(clusteringKey)) { + throw new IllegalArgumentException( + CoreError.VIRTUAL_TABLE_SOURCE_TABLES_HAVE_DIFFERENT_PRIMARY_KEY_TYPES.buildMessage( + clusteringKey, + ScalarDbUtils.getFullTableName(leftSourceNamespace, leftSourceTable), + ScalarDbUtils.getFullTableName(rightSourceNamespace, rightSourceTable))); + } + if (leftSourceTableMetadata.getClusteringOrder(clusteringKey) + != rightSourceTableMetadata.getClusteringOrder(clusteringKey)) { + throw new IllegalArgumentException( + CoreError.VIRTUAL_TABLE_SOURCE_TABLES_HAVE_DIFFERENT_CLUSTERING_ORDERS.buildMessage( + clusteringKey, + ScalarDbUtils.getFullTableName(leftSourceNamespace, leftSourceTable), + ScalarDbUtils.getFullTableName(rightSourceNamespace, rightSourceTable))); + } + } + + // Check for non-key column name conflicts between sources + Set primaryKeyColumns = new HashSet<>(leftSourceTableMetadata.getPartitionKeyNames()); + primaryKeyColumns.addAll(leftSourceTableMetadata.getClusteringKeyNames()); + + Set leftNonKeyColumns = new HashSet<>(leftSourceTableMetadata.getColumnNames()); + leftNonKeyColumns.removeAll(primaryKeyColumns); + + Set rightNonKeyColumns = new HashSet<>(rightSourceTableMetadata.getColumnNames()); + rightNonKeyColumns.removeAll(primaryKeyColumns); + + Set conflictingColumns = new HashSet<>(leftNonKeyColumns); + conflictingColumns.retainAll(rightNonKeyColumns); + + if (!conflictingColumns.isEmpty()) { + throw new IllegalArgumentException( + CoreError.VIRTUAL_TABLE_SOURCE_TABLES_HAVE_CONFLICTING_COLUMN_NAMES.buildMessage( + ScalarDbUtils.getFullTableName(leftSourceNamespace, leftSourceTable), + ScalarDbUtils.getFullTableName(rightSourceNamespace, rightSourceTable), + conflictingColumns)); + } + + // Check that virtual tables are not used as sources + Optional leftSourceTableInfo = + getVirtualTableInfo(leftSourceNamespace, leftSourceTable); + if (leftSourceTableInfo.isPresent()) { + throw new IllegalArgumentException( + CoreError.VIRTUAL_TABLE_CANNOT_USE_VIRTUAL_TABLE_AS_SOURCE.buildMessage( + ScalarDbUtils.getFullTableName(leftSourceNamespace, leftSourceTable))); + } + + Optional rightSourceTableInfo = + getVirtualTableInfo(rightSourceNamespace, rightSourceTable); + if (rightSourceTableInfo.isPresent()) { + throw new IllegalArgumentException( + CoreError.VIRTUAL_TABLE_CANNOT_USE_VIRTUAL_TABLE_AS_SOURCE.buildMessage( + ScalarDbUtils.getFullTableName(rightSourceNamespace, rightSourceTable))); + } + + try { + admin.createVirtualTable( + namespace, + table, + leftSourceNamespace, + leftSourceTable, + rightSourceNamespace, + rightSourceTable, + joinType, + options); + } catch (ExecutionException e) { + throw new ExecutionException( + CoreError.CREATING_VIRTUAL_TABLE_FAILED.buildMessage( + ScalarDbUtils.getFullTableName(namespace, table), + ScalarDbUtils.getFullTableName(leftSourceNamespace, leftSourceTable), + ScalarDbUtils.getFullTableName(rightSourceNamespace, rightSourceTable)), + e); + } + } + + @Override + public Optional getVirtualTableInfo(String namespace, String table) + throws ExecutionException { + if (!tableExists(namespace, table)) { + throw new IllegalArgumentException( + CoreError.TABLE_NOT_FOUND.buildMessage(ScalarDbUtils.getFullTableName(namespace, table))); + } + + try { + return admin.getVirtualTableInfo(namespace, table); + } catch (ExecutionException e) { + throw new ExecutionException( + CoreError.GETTING_VIRTUAL_TABLE_INFO_FAILED.buildMessage( + ScalarDbUtils.getFullTableName(namespace, table)), + e); + } + } + @Override public void close() { admin.close(); diff --git a/core/src/main/java/com/scalar/db/common/CoreError.java b/core/src/main/java/com/scalar/db/common/CoreError.java index 8473597b65..6676037030 100644 --- a/core/src/main/java/com/scalar/db/common/CoreError.java +++ b/core/src/main/java/com/scalar/db/common/CoreError.java @@ -943,6 +943,72 @@ public enum CoreError implements ScalarDbError { "Failed to load the service account key for Cloud Storage.", "", ""), + VIRTUAL_TABLE_NOT_SUPPORTED_IN_STORAGE( + Category.USER_ERROR, + "0265", + "To support virtual tables, the atomicity unit of the storage must be at least at the namespace level. Storage: %s; Atomicity unit: %s", + "", + ""), + VIRTUAL_TABLE_SOURCE_TABLES_OUTSIDE_OF_ATOMICITY_UNIT( + Category.USER_ERROR, + "0266", + "The source tables must reside within the atomicity unit of the storage. Storage: %s; Atomicity unit: %s; Left source table: %s; Right source table: %s", + "", + ""), + DYNAMO_VIRTUAL_TABLE_NOT_SUPPORTED( + Category.USER_ERROR, + "0267", + "The virtual table functionality is not supported in DynamoDB", + "", + ""), + VIRTUAL_TABLE_SOURCE_TABLES_HAVE_DIFFERENT_PRIMARY_KEY( + Category.USER_ERROR, + "0268", + "The source tables must have the same primary key. Left source table: %s; Right source table: %s", + "", + ""), + VIRTUAL_TABLE_SOURCE_TABLES_HAVE_DIFFERENT_PRIMARY_KEY_TYPES( + Category.USER_ERROR, + "0269", + "The source tables must have the same data types for primary key column. Column: %s; Left source table: %s; Right source table: %s", + "", + ""), + VIRTUAL_TABLE_SOURCE_TABLES_HAVE_DIFFERENT_CLUSTERING_ORDERS( + Category.USER_ERROR, + "0270", + "The source tables must have the same clustering orders for clustering key column. Column: %s; Left source table: %s; Right source table: %s", + "", + ""), + VIRTUAL_TABLE_SOURCE_TABLES_HAVE_CONFLICTING_COLUMN_NAMES( + Category.USER_ERROR, + "0271", + "The source tables have conflicting non-key column names. Left source table: %s; Right source table: %s; Conflicting columns: %s", + "", + ""), + VIRTUAL_TABLE_CANNOT_USE_VIRTUAL_TABLE_AS_SOURCE( + Category.USER_ERROR, + "0272", + "Virtual tables cannot be used as source tables. Source table: %s", + "", + ""), + VIRTUAL_TABLE_SOURCE_TABLES_IN_DIFFERENT_STORAGES( + Category.USER_ERROR, + "0273", + "The source tables must be in the same storage. Left source table: %s; Right source table: %s", + "", + ""), + VIRTUAL_TABLE_IN_DIFFERENT_STORAGE_FROM_SOURCE_TABLES( + Category.USER_ERROR, + "0274", + "The virtual table must be in the same storage as its source tables. Virtual table: %s; Left source table: %s; Right source table: %s", + "", + ""), + SOURCE_TABLES_CANNOT_BE_DROPPED_WHILE_VIRTUAL_TABLES_EXIST( + Category.USER_ERROR, + "0275", + "Source tables cannot be dropped while virtual tables depending on them exist. Source table: %s; Virtual tables: %s", + "", + ""), // // Errors for the concurrency error category @@ -1265,6 +1331,18 @@ public enum CoreError implements ScalarDbError { Category.INTERNAL_ERROR, "0064", "An error occurred in the selection. Details: %s", "", ""), OBJECT_STORAGE_ERROR_OCCURRED_IN_MUTATION( Category.INTERNAL_ERROR, "0065", "An error occurred in the mutation. Details: %s", "", ""), + CREATING_VIRTUAL_TABLE_FAILED( + Category.INTERNAL_ERROR, + "0066", + "Creating the virtual table failed. Virtual table: %s; Left source table: %s; Right source table: %s", + "", + ""), + GETTING_VIRTUAL_TABLE_INFO_FAILED( + Category.INTERNAL_ERROR, + "0067", + "Getting the virtual table information failed. Table: %s", + "", + ""), // // Errors for the unknown transaction status error category diff --git a/core/src/main/java/com/scalar/db/common/VirtualTableInfoManager.java b/core/src/main/java/com/scalar/db/common/VirtualTableInfoManager.java new file mode 100644 index 0000000000..96a6fae0d4 --- /dev/null +++ b/core/src/main/java/com/scalar/db/common/VirtualTableInfoManager.java @@ -0,0 +1,99 @@ +package com.scalar.db.common; + +import com.github.benmanes.caffeine.cache.Caffeine; +import com.github.benmanes.caffeine.cache.LoadingCache; +import com.google.common.annotations.VisibleForTesting; +import com.scalar.db.api.DistributedStorageAdmin; +import com.scalar.db.api.Operation; +import com.scalar.db.api.VirtualTableInfo; +import com.scalar.db.exception.storage.ExecutionException; +import com.scalar.db.util.ScalarDbUtils; +import java.util.Objects; +import java.util.concurrent.CompletionException; +import java.util.concurrent.TimeUnit; +import javax.annotation.Nullable; +import javax.annotation.concurrent.ThreadSafe; + +/** A class that manages and caches virtual table information */ +@ThreadSafe +public class VirtualTableInfoManager { + + private final LoadingCache virtualTableInfoCache; + + public VirtualTableInfoManager(DistributedStorageAdmin admin, long cacheExpirationTimeSecs) { + Caffeine builder = Caffeine.newBuilder(); + if (cacheExpirationTimeSecs >= 0) { + builder.expireAfterWrite(cacheExpirationTimeSecs, TimeUnit.SECONDS); + } + virtualTableInfoCache = + builder.build(key -> admin.getVirtualTableInfo(key.namespace, key.table).orElse(null)); + } + + /** + * Returns virtual table information corresponding to the specified operation. + * + * @param operation an operation + * @return the virtual table information or null if the table is not a virtual table + * @throws ExecutionException if the operation fails + * @throws IllegalArgumentException if the table does not exist + */ + @Nullable + public VirtualTableInfo getVirtualTableInfo(Operation operation) throws ExecutionException { + if (!operation.forNamespace().isPresent() || !operation.forTable().isPresent()) { + throw new IllegalArgumentException( + CoreError.OPERATION_DOES_NOT_HAVE_TARGET_NAMESPACE_OR_TABLE_NAME.buildMessage(operation)); + } + return getVirtualTableInfo(operation.forNamespace().get(), operation.forTable().get()); + } + + /** + * Returns virtual table information corresponding to the specified namespace and table. + * + * @param namespace a namespace to retrieve + * @param table a table to retrieve + * @return the virtual table information or null if the table is not a virtual table + * @throws ExecutionException if the operation fails + * @throws IllegalArgumentException if the table does not exist + */ + @Nullable + public VirtualTableInfo getVirtualTableInfo(String namespace, String table) + throws ExecutionException { + try { + TableKey key = new TableKey(namespace, table); + return virtualTableInfoCache.get(key); + } catch (CompletionException e) { + throw new ExecutionException( + CoreError.GETTING_VIRTUAL_TABLE_INFO_FAILED.buildMessage( + ScalarDbUtils.getFullTableName(namespace, table)), + e.getCause()); + } + } + + @VisibleForTesting + static class TableKey { + public final String namespace; + public final String table; + + public TableKey(String namespace, String table) { + this.namespace = namespace; + this.table = table; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof TableKey)) { + return false; + } + TableKey tableKey = (TableKey) o; + return namespace.equals(tableKey.namespace) && table.equals(tableKey.table); + } + + @Override + public int hashCode() { + return Objects.hash(namespace, table); + } + } +} diff --git a/core/src/main/java/com/scalar/db/service/AdminService.java b/core/src/main/java/com/scalar/db/service/AdminService.java index ba3989bb0c..f59a1f7a56 100644 --- a/core/src/main/java/com/scalar/db/service/AdminService.java +++ b/core/src/main/java/com/scalar/db/service/AdminService.java @@ -4,10 +4,13 @@ import com.scalar.db.api.DistributedStorageAdmin; import com.scalar.db.api.StorageInfo; import com.scalar.db.api.TableMetadata; +import com.scalar.db.api.VirtualTableInfo; +import com.scalar.db.api.VirtualTableJoinType; import com.scalar.db.exception.storage.ExecutionException; import com.scalar.db.io.DataType; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import java.util.Map; +import java.util.Optional; import java.util.Set; import javax.annotation.concurrent.ThreadSafe; @@ -140,6 +143,34 @@ public StorageInfo getStorageInfo(String namespace) throws ExecutionException { return admin.getStorageInfo(namespace); } + @Override + public void createVirtualTable( + String namespace, + String table, + String leftSourceNamespace, + String leftSourceTable, + String rightSourceNamespace, + String rightSourceTable, + VirtualTableJoinType joinType, + Map options) + throws ExecutionException { + admin.createVirtualTable( + namespace, + table, + leftSourceNamespace, + leftSourceTable, + rightSourceNamespace, + rightSourceTable, + joinType, + options); + } + + @Override + public Optional getVirtualTableInfo(String namespace, String table) + throws ExecutionException { + return admin.getVirtualTableInfo(namespace, table); + } + @Override public void close() { admin.close(); diff --git a/core/src/main/java/com/scalar/db/storage/cassandra/CassandraAdmin.java b/core/src/main/java/com/scalar/db/storage/cassandra/CassandraAdmin.java index b326cc2f99..70ba660499 100644 --- a/core/src/main/java/com/scalar/db/storage/cassandra/CassandraAdmin.java +++ b/core/src/main/java/com/scalar/db/storage/cassandra/CassandraAdmin.java @@ -20,6 +20,8 @@ import com.scalar.db.api.Scan.Ordering.Order; import com.scalar.db.api.StorageInfo; import com.scalar.db.api.TableMetadata; +import com.scalar.db.api.VirtualTableInfo; +import com.scalar.db.api.VirtualTableJoinType; import com.scalar.db.common.CoreError; import com.scalar.db.common.StorageInfoImpl; import com.scalar.db.config.DatabaseConfig; @@ -29,6 +31,7 @@ import java.util.LinkedHashMap; import java.util.Locale; import java.util.Map; +import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -494,6 +497,25 @@ public StorageInfo getStorageInfo(String namespace) { return STORAGE_INFO; } + @Override + public void createVirtualTable( + String namespace, + String table, + String leftSourceNamespace, + String leftSourceTable, + String rightSourceNamespace, + String rightSourceTable, + VirtualTableJoinType joinType, + Map options) { + throw new AssertionError("CommonDistributedStorageAdmin should not call this method"); + } + + @Override + public Optional getVirtualTableInfo(String namespace, String table) { + // Virtual tables are not supported. + return Optional.empty(); + } + @Override public void close() { clusterManager.close(); diff --git a/core/src/main/java/com/scalar/db/storage/cosmos/CosmosAdmin.java b/core/src/main/java/com/scalar/db/storage/cosmos/CosmosAdmin.java index 986424ae3b..2bac099d64 100644 --- a/core/src/main/java/com/scalar/db/storage/cosmos/CosmosAdmin.java +++ b/core/src/main/java/com/scalar/db/storage/cosmos/CosmosAdmin.java @@ -26,6 +26,8 @@ import com.scalar.db.api.Scan.Ordering.Order; import com.scalar.db.api.StorageInfo; import com.scalar.db.api.TableMetadata; +import com.scalar.db.api.VirtualTableInfo; +import com.scalar.db.api.VirtualTableJoinType; import com.scalar.db.common.CoreError; import com.scalar.db.common.StorageInfoImpl; import com.scalar.db.config.DatabaseConfig; @@ -42,6 +44,7 @@ import java.util.List; import java.util.Map; import java.util.Map.Entry; +import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; import javax.annotation.concurrent.ThreadSafe; @@ -667,6 +670,25 @@ public StorageInfo getStorageInfo(String namespace) { return STORAGE_INFO; } + @Override + public void createVirtualTable( + String namespace, + String table, + String leftSourceNamespace, + String leftSourceTable, + String rightSourceNamespace, + String rightSourceTable, + VirtualTableJoinType joinType, + Map options) { + throw new AssertionError("CommonDistributedStorageAdmin should not call this method"); + } + + @Override + public Optional getVirtualTableInfo(String namespace, String table) { + // Virtual tables are not supported. + return Optional.empty(); + } + private Set getRawTableNames(String namespace) { return client.getDatabase(namespace).readAllContainers().stream() .map(CosmosContainerProperties::getId) diff --git a/core/src/main/java/com/scalar/db/storage/dynamo/DynamoAdmin.java b/core/src/main/java/com/scalar/db/storage/dynamo/DynamoAdmin.java index 310f48523f..6c7149ecd2 100644 --- a/core/src/main/java/com/scalar/db/storage/dynamo/DynamoAdmin.java +++ b/core/src/main/java/com/scalar/db/storage/dynamo/DynamoAdmin.java @@ -9,6 +9,8 @@ import com.scalar.db.api.Scan.Ordering.Order; import com.scalar.db.api.StorageInfo; import com.scalar.db.api.TableMetadata; +import com.scalar.db.api.VirtualTableInfo; +import com.scalar.db.api.VirtualTableJoinType; import com.scalar.db.common.CoreError; import com.scalar.db.common.StorageInfoImpl; import com.scalar.db.config.DatabaseConfig; @@ -25,6 +27,7 @@ import java.util.List; import java.util.Map; import java.util.Map.Entry; +import java.util.Optional; import java.util.Set; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; @@ -1328,6 +1331,26 @@ public StorageInfo getStorageInfo(String namespace) { return STORAGE_INFO; } + @Override + public void createVirtualTable( + String namespace, + String table, + String leftSourceNamespace, + String leftSourceTable, + String rightSourceNamespace, + String rightSourceTable, + VirtualTableJoinType joinType, + Map options) { + throw new UnsupportedOperationException( + CoreError.DYNAMO_VIRTUAL_TABLE_NOT_SUPPORTED.buildMessage()); + } + + @Override + public Optional getVirtualTableInfo(String namespace, String table) { + // Virtual tables are not supported. + return Optional.empty(); + } + @Override public void close() { client.close(); diff --git a/core/src/main/java/com/scalar/db/storage/jdbc/JdbcAdmin.java b/core/src/main/java/com/scalar/db/storage/jdbc/JdbcAdmin.java index 6a9cdae141..ee4d9a74e3 100644 --- a/core/src/main/java/com/scalar/db/storage/jdbc/JdbcAdmin.java +++ b/core/src/main/java/com/scalar/db/storage/jdbc/JdbcAdmin.java @@ -12,6 +12,8 @@ import com.scalar.db.api.Scan.Ordering.Order; import com.scalar.db.api.StorageInfo; import com.scalar.db.api.TableMetadata; +import com.scalar.db.api.VirtualTableInfo; +import com.scalar.db.api.VirtualTableJoinType; import com.scalar.db.common.CoreError; import com.scalar.db.common.StorageInfoImpl; import com.scalar.db.config.DatabaseConfig; @@ -28,6 +30,7 @@ import java.util.Collections; import java.util.HashSet; import java.util.LinkedHashSet; +import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; @@ -61,6 +64,7 @@ public class JdbcAdmin implements DistributedStorageAdmin { private final BasicDataSource dataSource; private final String metadataSchema; private final TableMetadataService tableMetadataService; + private final VirtualTableMetadataService virtualTableMetadataService; @Inject public JdbcAdmin(DatabaseConfig databaseConfig) { @@ -70,6 +74,7 @@ public JdbcAdmin(DatabaseConfig databaseConfig) { metadataSchema = config.getTableMetadataSchema().orElse(DatabaseConfig.DEFAULT_SYSTEM_NAMESPACE_NAME); tableMetadataService = new TableMetadataService(metadataSchema, rdbEngine); + virtualTableMetadataService = new VirtualTableMetadataService(metadataSchema, rdbEngine); } @SuppressFBWarnings("EI_EXPOSE_REP2") @@ -79,6 +84,36 @@ public JdbcAdmin(BasicDataSource dataSource, JdbcConfig config) { metadataSchema = config.getTableMetadataSchema().orElse(DatabaseConfig.DEFAULT_SYSTEM_NAMESPACE_NAME); tableMetadataService = new TableMetadataService(metadataSchema, rdbEngine); + virtualTableMetadataService = new VirtualTableMetadataService(metadataSchema, rdbEngine); + } + + @VisibleForTesting + @SuppressFBWarnings("EI_EXPOSE_REP2") + public JdbcAdmin( + BasicDataSource dataSource, + JdbcConfig config, + VirtualTableMetadataService virtualTableMetadataService) { + rdbEngine = RdbEngineFactory.create(config); + this.dataSource = dataSource; + metadataSchema = + config.getTableMetadataSchema().orElse(DatabaseConfig.DEFAULT_SYSTEM_NAMESPACE_NAME); + tableMetadataService = new TableMetadataService(metadataSchema, rdbEngine); + this.virtualTableMetadataService = virtualTableMetadataService; + } + + @VisibleForTesting + @SuppressFBWarnings("EI_EXPOSE_REP2") + public JdbcAdmin( + BasicDataSource dataSource, + JdbcConfig config, + TableMetadataService tableMetadataService, + VirtualTableMetadataService virtualTableMetadataService) { + rdbEngine = RdbEngineFactory.create(config); + this.dataSource = dataSource; + metadataSchema = + config.getTableMetadataSchema().orElse(DatabaseConfig.DEFAULT_SYSTEM_NAMESPACE_NAME); + this.tableMetadataService = tableMetadataService; + this.virtualTableMetadataService = virtualTableMetadataService; } private static boolean hasDescClusteringOrder(TableMetadata metadata) { @@ -196,6 +231,34 @@ private void createIndex( @Override public void dropTable(String namespace, String table) throws ExecutionException { try (Connection connection = dataSource.getConnection()) { + VirtualTableInfo virtualTableInfo = + virtualTableMetadataService.getVirtualTableInfo(connection, namespace, table); + if (virtualTableInfo != null) { + // For a virtual table + + // Drop the virtual table view + dropVirtualTableView(connection, namespace, table); + + // Delete its metadata + virtualTableMetadataService.deleteFromVirtualTablesTable(connection, namespace, table); + virtualTableMetadataService.deleteVirtualTablesTableIfEmpty(connection); + return; + } + + List virtualTableInfos = + virtualTableMetadataService.getVirtualTableInfosBySourceTable( + connection, namespace, table); + if (!virtualTableInfos.isEmpty()) { + // For a source table of virtual tables + throw new IllegalArgumentException( + CoreError.SOURCE_TABLES_CANNOT_BE_DROPPED_WHILE_VIRTUAL_TABLES_EXIST.buildMessage( + getFullTableName(namespace, table), + virtualTableInfos.stream() + .map(v -> getFullTableName(v.getNamespaceName(), v.getTableName())) + .collect(Collectors.joining(",")))); + } + + // For a regular table dropTableInternal(connection, namespace, table); tableMetadataService.deleteTableMetadata(connection, namespace, table, true); deleteMetadataSchemaIfEmpty(connection); @@ -246,8 +309,28 @@ private Set getInternalTableNames(Connection connection, String namespac @Override public void truncateTable(String namespace, String table) throws ExecutionException { - String truncateTableStatement = rdbEngine.truncateTableSql(namespace, table); try (Connection connection = dataSource.getConnection()) { + VirtualTableInfo virtualTableInfo = + virtualTableMetadataService.getVirtualTableInfo(connection, namespace, table); + if (virtualTableInfo != null) { + // For a virtual table + + // truncate the source tables + execute( + connection, + rdbEngine.truncateTableSql( + virtualTableInfo.getLeftSourceNamespaceName(), + virtualTableInfo.getLeftSourceTableName())); + execute( + connection, + rdbEngine.truncateTableSql( + virtualTableInfo.getRightSourceNamespaceName(), + virtualTableInfo.getRightSourceTableName())); + return; + } + + // For a regular table + String truncateTableStatement = rdbEngine.truncateTableSql(namespace, table); execute(connection, truncateTableStatement); } catch (SQLException e) { throw new ExecutionException( @@ -255,11 +338,38 @@ public void truncateTable(String namespace, String table) throws ExecutionExcept } } + @Nullable @Override public TableMetadata getTableMetadata(String namespace, String table) throws ExecutionException { try (Connection connection = dataSource.getConnection()) { rdbEngine.setConnectionToReadOnly(connection, true); - return tableMetadataService.getTableMetadata(connection, namespace, table); + + // If it's a regular table, return its metadata + TableMetadata tableMetadata = + tableMetadataService.getTableMetadata(connection, namespace, table); + if (tableMetadata != null) { + return tableMetadata; + } + + // If it's a virtual table, merge the source table metadata and return it + VirtualTableInfo virtualTableInfo = + virtualTableMetadataService.getVirtualTableInfo(connection, namespace, table); + if (virtualTableInfo != null) { + TableMetadata leftSourceTableMetadata = + tableMetadataService.getTableMetadata( + connection, + virtualTableInfo.getLeftSourceNamespaceName(), + virtualTableInfo.getLeftSourceTableName()); + TableMetadata rightSourceTableMetadata = + tableMetadataService.getTableMetadata( + connection, + virtualTableInfo.getRightSourceNamespaceName(), + virtualTableInfo.getRightSourceTableName()); + assert leftSourceTableMetadata != null && rightSourceTableMetadata != null; + return mergeSourceTableMetadata(leftSourceTableMetadata, rightSourceTableMetadata); + } + + return null; } catch (SQLException e) { throw new ExecutionException( "Getting a table metadata for the " @@ -269,6 +379,53 @@ public TableMetadata getTableMetadata(String namespace, String table) throws Exe } } + private TableMetadata mergeSourceTableMetadata( + TableMetadata leftSourceTableMetadata, TableMetadata rightSourceTableMetadata) { + TableMetadata.Builder builder = TableMetadata.newBuilder(); + + // Add partition keys from the left source table (both tables should have the same partition + // keys) + for (String partitionKey : leftSourceTableMetadata.getPartitionKeyNames()) { + builder.addPartitionKey(partitionKey); + } + + // Add clustering keys with their ordering from the left source table (both tables should have + // the same clustering keys) + for (String clusteringKey : leftSourceTableMetadata.getClusteringKeyNames()) { + builder.addClusteringKey( + clusteringKey, leftSourceTableMetadata.getClusteringOrder(clusteringKey)); + } + + // Get primary key columns to avoid duplicates + Set primaryKeyColumns = + Sets.newHashSet( + Iterables.concat( + leftSourceTableMetadata.getPartitionKeyNames(), + leftSourceTableMetadata.getClusteringKeyNames())); + + // Add all columns from the left source table + for (String columnName : leftSourceTableMetadata.getColumnNames()) { + builder.addColumn(columnName, leftSourceTableMetadata.getColumnDataType(columnName)); + } + + // Add non-primary key columns from the right source table + for (String columnName : rightSourceTableMetadata.getColumnNames()) { + if (!primaryKeyColumns.contains(columnName)) { + builder.addColumn(columnName, rightSourceTableMetadata.getColumnDataType(columnName)); + } + } + + // Add secondary indexes from both tables + for (String secondaryIndex : leftSourceTableMetadata.getSecondaryIndexNames()) { + builder.addSecondaryIndex(secondaryIndex); + } + for (String secondaryIndex : rightSourceTableMetadata.getSecondaryIndexNames()) { + builder.addSecondaryIndex(secondaryIndex); + } + + return builder.build(); + } + @VisibleForTesting TableMetadata getImportTableMetadata( String namespace, String table, Map overrideColumnsType) @@ -349,7 +506,12 @@ public void importTable( public Set getNamespaceTableNames(String namespace) throws ExecutionException { try (Connection connection = dataSource.getConnection()) { rdbEngine.setConnectionToReadOnly(connection, true); - return tableMetadataService.getNamespaceTableNames(connection, namespace); + + // Get both regular and virtual table names + Set tableNames = new HashSet<>(); + tableNames.addAll(tableMetadataService.getNamespaceTableNames(connection, namespace)); + tableNames.addAll(virtualTableMetadataService.getNamespaceTableNames(connection, namespace)); + return tableNames; } catch (SQLException e) { throw new ExecutionException( "Getting the list of tables of the " + namespace + " schema failed", e); @@ -418,10 +580,50 @@ public void createIndex( String namespace, String table, String columnName, Map options) throws ExecutionException { try (Connection connection = dataSource.getConnection()) { - alterToIndexColumnTypeIfNecessary(connection, namespace, table, columnName); - createIndex(connection, namespace, table, columnName, false); - tableMetadataService.updateTableMetadata(connection, namespace, table, columnName, true); - } catch (ExecutionException | SQLException e) { + VirtualTableInfo virtualTableInfo = + virtualTableMetadataService.getVirtualTableInfo(connection, namespace, table); + if (virtualTableInfo != null) { + // For a virtual table + + TableMetadata leftSourceTableMetadata = + tableMetadataService.getTableMetadata( + connection, + virtualTableInfo.getLeftSourceNamespaceName(), + virtualTableInfo.getLeftSourceTableName()); + if (leftSourceTableMetadata.getColumnNames().contains(columnName)) { + // If the column exists in the left source table, create the index there + createIndexInternal( + connection, + virtualTableInfo.getLeftSourceNamespaceName(), + virtualTableInfo.getLeftSourceTableName(), + columnName, + leftSourceTableMetadata.getColumnDataType(columnName)); + } + + TableMetadata rightSourceTableMetadata = + tableMetadataService.getTableMetadata( + connection, + virtualTableInfo.getRightSourceNamespaceName(), + virtualTableInfo.getRightSourceTableName()); + if (rightSourceTableMetadata.getColumnNames().contains(columnName)) { + // If the column exists in the right source table, create the index there + createIndexInternal( + connection, + virtualTableInfo.getRightSourceNamespaceName(), + virtualTableInfo.getRightSourceTableName(), + columnName, + rightSourceTableMetadata.getColumnDataType(columnName)); + } + + return; + } + + // For a regular table + TableMetadata tableMetadata = + tableMetadataService.getTableMetadata(connection, namespace, table); + createIndexInternal( + connection, namespace, table, columnName, tableMetadata.getColumnDataType(columnName)); + } catch (SQLException e) { throw new ExecutionException( String.format( "Creating the secondary index on the %s column for the %s table failed", @@ -430,11 +632,18 @@ columnName, getFullTableName(namespace, table)), } } + private void createIndexInternal( + Connection connection, String namespace, String table, String columnName, DataType dataType) + throws SQLException { + alterToIndexColumnTypeIfNecessary(connection, namespace, table, columnName, dataType); + createIndex(connection, namespace, table, columnName, false); + tableMetadataService.updateTableMetadata(connection, namespace, table, columnName, true); + } + private void alterToIndexColumnTypeIfNecessary( - Connection connection, String namespace, String table, String columnName) - throws ExecutionException, SQLException { - DataType indexType = getTableMetadata(namespace, table).getColumnDataType(columnName); - String columnTypeForKey = rdbEngine.getDataTypeForSecondaryIndex(indexType); + Connection connection, String namespace, String table, String columnName, DataType dataType) + throws SQLException { + String columnTypeForKey = rdbEngine.getDataTypeForSecondaryIndex(dataType); if (columnTypeForKey == null) { // The column type does not need to be altered to be compatible with being secondary index return; @@ -447,17 +656,16 @@ private void alterToIndexColumnTypeIfNecessary( } private void alterToRegularColumnTypeIfNecessary( - Connection connection, String namespace, String table, String columnName) - throws ExecutionException, SQLException { - DataType indexType = getTableMetadata(namespace, table).getColumnDataType(columnName); - String columnTypeForKey = rdbEngine.getDataTypeForSecondaryIndex(indexType); + Connection connection, String namespace, String table, String columnName, DataType dataType) + throws SQLException { + String columnTypeForKey = rdbEngine.getDataTypeForSecondaryIndex(dataType); if (columnTypeForKey == null) { // The column type is already the type for a regular column. It was not altered to be // compatible with being a secondary index, so no alteration is necessary. return; } - String columnType = rdbEngine.getDataTypeForEngine(indexType); + String columnType = rdbEngine.getDataTypeForEngine(dataType); String[] sqls = rdbEngine.alterColumnTypeSql(namespace, table, columnName, columnType); for (String sql : sqls) { execute(connection, sql); @@ -468,9 +676,49 @@ private void alterToRegularColumnTypeIfNecessary( public void dropIndex(String namespace, String table, String columnName) throws ExecutionException { try (Connection connection = dataSource.getConnection()) { - dropIndex(connection, namespace, table, columnName); - alterToRegularColumnTypeIfNecessary(connection, namespace, table, columnName); - tableMetadataService.updateTableMetadata(connection, namespace, table, columnName, false); + VirtualTableInfo virtualTableInfo = + virtualTableMetadataService.getVirtualTableInfo(connection, namespace, table); + if (virtualTableInfo != null) { + // For a virtual table + + TableMetadata leftSourceTableMetadata = + tableMetadataService.getTableMetadata( + connection, + virtualTableInfo.getLeftSourceNamespaceName(), + virtualTableInfo.getLeftSourceTableName()); + if (leftSourceTableMetadata.getColumnNames().contains(columnName)) { + // If the column exists in the left source table, drop the index there + dropIndexInternal( + connection, + virtualTableInfo.getLeftSourceNamespaceName(), + virtualTableInfo.getLeftSourceTableName(), + columnName, + leftSourceTableMetadata.getColumnDataType(columnName)); + } + + TableMetadata rightSourceTableMetadata = + tableMetadataService.getTableMetadata( + connection, + virtualTableInfo.getRightSourceNamespaceName(), + virtualTableInfo.getRightSourceTableName()); + if (rightSourceTableMetadata.getColumnNames().contains(columnName)) { + // If the column exists in the right source table, drop the index there + dropIndexInternal( + connection, + virtualTableInfo.getRightSourceNamespaceName(), + virtualTableInfo.getRightSourceTableName(), + columnName, + rightSourceTableMetadata.getColumnDataType(columnName)); + } + + return; + } + + // For a regular table + TableMetadata tableMetadata = + tableMetadataService.getTableMetadata(connection, namespace, table); + dropIndexInternal( + connection, namespace, table, columnName, tableMetadata.getColumnDataType(columnName)); } catch (SQLException e) { throw new ExecutionException( String.format( @@ -480,6 +728,14 @@ columnName, getFullTableName(namespace, table)), } } + private void dropIndexInternal( + Connection connection, String namespace, String table, String columnName, DataType dataType) + throws SQLException { + dropIndex(connection, namespace, table, columnName); + alterToRegularColumnTypeIfNecessary(connection, namespace, table, columnName, dataType); + tableMetadataService.updateTableMetadata(connection, namespace, table, columnName, false); + } + @Override public void repairTable( String namespace, String table, TableMetadata metadata, Map options) @@ -487,6 +743,8 @@ public void repairTable( rdbEngine.throwIfInvalidNamespaceName(table); try (Connection connection = dataSource.getConnection()) { + throwIfVirtualTableOrSourceTable(connection, namespace, table, "repairTable()"); + if (!internalTableExists(connection, namespace, table)) { throw new IllegalArgumentException( CoreError.TABLE_NOT_FOUND.buildMessage(getFullTableName(namespace, table))); @@ -504,8 +762,11 @@ public void repairTable( public void addNewColumnToTable( String namespace, String table, String columnName, DataType columnType) throws ExecutionException { - try { - TableMetadata currentTableMetadata = getTableMetadata(namespace, table); + try (Connection connection = dataSource.getConnection()) { + throwIfVirtualTableOrSourceTable(connection, namespace, table, "addNewColumnToTable()"); + + TableMetadata currentTableMetadata = + tableMetadataService.getTableMetadata(connection, namespace, table); TableMetadata updatedTableMetadata = TableMetadata.newBuilder(currentTableMetadata).addColumn(columnName, columnType).build(); String addNewColumnStatement = @@ -515,10 +776,8 @@ public void addNewColumnToTable( + enclose(columnName) + " " + getVendorDbColumnType(updatedTableMetadata, columnName); - try (Connection connection = dataSource.getConnection()) { - execute(connection, addNewColumnStatement); - addTableMetadata(connection, namespace, table, updatedTableMetadata, false, true); - } + execute(connection, addNewColumnStatement); + addTableMetadata(connection, namespace, table, updatedTableMetadata, false, true); } catch (SQLException e) { throw new ExecutionException( String.format( @@ -531,17 +790,19 @@ columnName, getFullTableName(namespace, table)), @Override public void dropColumnFromTable(String namespace, String table, String columnName) throws ExecutionException { - try { - TableMetadata currentTableMetadata = getTableMetadata(namespace, table); + try (Connection connection = dataSource.getConnection()) { + throwIfVirtualTableOrSourceTable(connection, namespace, table, "dropColumnFromTable()"); + + TableMetadata currentTableMetadata = + tableMetadataService.getTableMetadata(connection, namespace, table); TableMetadata updatedTableMetadata = TableMetadata.newBuilder(currentTableMetadata).removeColumn(columnName).build(); String[] dropColumnStatements = rdbEngine.dropColumnSql(namespace, table, columnName); - try (Connection connection = dataSource.getConnection()) { - for (String dropColumnStatement : dropColumnStatements) { - execute(connection, dropColumnStatement); - } - addTableMetadata(connection, namespace, table, updatedTableMetadata, false, true); + + for (String dropColumnStatement : dropColumnStatements) { + execute(connection, dropColumnStatement); } + addTableMetadata(connection, namespace, table, updatedTableMetadata, false, true); } catch (SQLException e) { throw new ExecutionException( String.format( @@ -555,8 +816,11 @@ columnName, getFullTableName(namespace, table)), public void renameColumn( String namespace, String table, String oldColumnName, String newColumnName) throws ExecutionException { - try { - TableMetadata currentTableMetadata = getTableMetadata(namespace, table); + try (Connection connection = dataSource.getConnection()) { + throwIfVirtualTableOrSourceTable(connection, namespace, table, "renameColumn()"); + + TableMetadata currentTableMetadata = + tableMetadataService.getTableMetadata(connection, namespace, table); assert currentTableMetadata != null; rdbEngine.throwIfRenameColumnNotSupported(oldColumnName, currentTableMetadata); TableMetadata.Builder tableMetadataBuilder = @@ -578,16 +842,15 @@ public void renameColumn( oldColumnName, newColumnName, getVendorDbColumnType(updatedTableMetadata, newColumnName)); - try (Connection connection = dataSource.getConnection()) { - execute(connection, renameColumnStatement); - if (currentTableMetadata.getSecondaryIndexNames().contains(oldColumnName)) { - String oldIndexName = getIndexName(namespace, table, oldColumnName); - String newIndexName = getIndexName(namespace, table, newColumnName); - renameIndexInternal( - connection, namespace, table, newColumnName, oldIndexName, newIndexName); - } - addTableMetadata(connection, namespace, table, updatedTableMetadata, false, true); + + execute(connection, renameColumnStatement); + if (currentTableMetadata.getSecondaryIndexNames().contains(oldColumnName)) { + String oldIndexName = getIndexName(namespace, table, oldColumnName); + String newIndexName = getIndexName(namespace, table, newColumnName); + renameIndexInternal( + connection, namespace, table, newColumnName, oldIndexName, newIndexName); } + addTableMetadata(connection, namespace, table, updatedTableMetadata, false, true); } catch (SQLException e) { throw new ExecutionException( String.format( @@ -601,8 +864,11 @@ oldColumnName, newColumnName, getFullTableName(namespace, table)), public void alterColumnType( String namespace, String table, String columnName, DataType newColumnType) throws ExecutionException { - try { - TableMetadata currentTableMetadata = getTableMetadata(namespace, table); + try (Connection connection = dataSource.getConnection()) { + throwIfVirtualTableOrSourceTable(connection, namespace, table, "alterColumnType()"); + + TableMetadata currentTableMetadata = + tableMetadataService.getTableMetadata(connection, namespace, table); assert currentTableMetadata != null; DataType currentColumnType = currentTableMetadata.getColumnDataType(columnName); rdbEngine.throwIfAlterColumnTypeNotSupported(currentColumnType, newColumnType); @@ -616,12 +882,10 @@ public void alterColumnType( String[] alterColumnTypeStatements = rdbEngine.alterColumnTypeSql(namespace, table, columnName, newStorageColumnType); - try (Connection connection = dataSource.getConnection()) { - for (String alterColumnTypeStatement : alterColumnTypeStatements) { - execute(connection, alterColumnTypeStatement); - } - addTableMetadata(connection, namespace, table, updatedTableMetadata, false, true); + for (String alterColumnTypeStatement : alterColumnTypeStatements) { + execute(connection, alterColumnTypeStatement); } + addTableMetadata(connection, namespace, table, updatedTableMetadata, false, true); } catch (SQLException e) { throw new ExecutionException( String.format( @@ -636,21 +900,23 @@ public void renameTable(String namespace, String oldTableName, String newTableNa throws ExecutionException { rdbEngine.throwIfInvalidTableName(newTableName); - try { - TableMetadata tableMetadata = getTableMetadata(namespace, oldTableName); + try (Connection connection = dataSource.getConnection()) { + throwIfVirtualTableOrSourceTable(connection, namespace, oldTableName, "renameTable()"); + + TableMetadata tableMetadata = + tableMetadataService.getTableMetadata(connection, namespace, oldTableName); assert tableMetadata != null; String renameTableStatement = rdbEngine.renameTableSql(namespace, oldTableName, newTableName); - try (Connection connection = dataSource.getConnection()) { - execute(connection, renameTableStatement); - tableMetadataService.deleteTableMetadata(connection, namespace, oldTableName, false); - for (String indexedColumnName : tableMetadata.getSecondaryIndexNames()) { - String oldIndexName = getIndexName(namespace, oldTableName, indexedColumnName); - String newIndexName = getIndexName(namespace, newTableName, indexedColumnName); - renameIndexInternal( - connection, namespace, newTableName, indexedColumnName, oldIndexName, newIndexName); - } - addTableMetadata(connection, namespace, newTableName, tableMetadata, false, false); + + execute(connection, renameTableStatement); + tableMetadataService.deleteTableMetadata(connection, namespace, oldTableName, false); + for (String indexedColumnName : tableMetadata.getSecondaryIndexNames()) { + String oldIndexName = getIndexName(namespace, oldTableName, indexedColumnName); + String newIndexName = getIndexName(namespace, newTableName, indexedColumnName); + renameIndexInternal( + connection, namespace, newTableName, indexedColumnName, oldIndexName, newIndexName); } + addTableMetadata(connection, namespace, newTableName, tableMetadata, false, false); } catch (SQLException e) { throw new ExecutionException( String.format( @@ -722,6 +988,199 @@ public StorageInfo getStorageInfo(String namespace) { return STORAGE_INFO; } + @Override + public void createVirtualTable( + String namespace, + String table, + String leftSourceNamespace, + String leftSourceTable, + String rightSourceNamespace, + String rightSourceTable, + VirtualTableJoinType joinType, + Map options) + throws ExecutionException { + rdbEngine.throwIfInvalidTableName(table); + + try (Connection connection = dataSource.getConnection()) { + // Create View + createVirtualTableView( + connection, + namespace, + table, + leftSourceNamespace, + leftSourceTable, + rightSourceNamespace, + rightSourceTable, + joinType); + + // Add metadata + createMetadataSchemaIfNotExists(connection); + virtualTableMetadataService.createVirtualTablesTableIfNotExists(connection); + virtualTableMetadataService.insertIntoVirtualTablesTable( + connection, + namespace, + table, + leftSourceNamespace, + leftSourceTable, + rightSourceNamespace, + rightSourceTable, + joinType, + ""); + } catch (SQLException e) { + throw new ExecutionException( + "Creating the virtual table " + + getFullTableName(namespace, table) + + " from source tables " + + getFullTableName(leftSourceNamespace, leftSourceTable) + + " and " + + getFullTableName(rightSourceNamespace, rightSourceTable) + + " failed", + e); + } + } + + @SuppressFBWarnings("SQL_NONCONSTANT_STRING_PASSED_TO_EXECUTE") + private void createVirtualTableView( + Connection connection, + String namespace, + String table, + String leftSourceNamespace, + String leftSourceTable, + String rightSourceNamespace, + String rightSourceTable, + VirtualTableJoinType joinType) + throws SQLException { + TableMetadata leftSourceTableMetadata = + tableMetadataService.getTableMetadata(connection, leftSourceNamespace, leftSourceTable); + TableMetadata rightSourceTableMetadata = + tableMetadataService.getTableMetadata(connection, rightSourceNamespace, rightSourceTable); + assert leftSourceTableMetadata != null && rightSourceTableMetadata != null; + + StringBuilder createViewSql = new StringBuilder("CREATE VIEW "); + createViewSql.append(encloseFullTableName(namespace, table)); + createViewSql.append(" AS SELECT "); + + // Add primary key columns from the left source table + for (String pkColumn : leftSourceTableMetadata.getPartitionKeyNames()) { + createViewSql.append("t1.").append(enclose(pkColumn)); + createViewSql.append(" AS ").append(enclose(pkColumn)).append(", "); + } + for (String pkColumn : leftSourceTableMetadata.getClusteringKeyNames()) { + createViewSql.append("t1.").append(enclose(pkColumn)); + createViewSql.append(" AS ").append(enclose(pkColumn)).append(", "); + } + + // Get primary key columns (same for both tables) + Set primaryKeyColumns = + Sets.newHashSet( + Iterables.concat( + leftSourceTableMetadata.getPartitionKeyNames(), + leftSourceTableMetadata.getClusteringKeyNames())); + + // Add non-primary key columns from the left source table + for (String column : leftSourceTableMetadata.getColumnNames()) { + if (!primaryKeyColumns.contains(column)) { + createViewSql.append("t1.").append(enclose(column)); + createViewSql.append(" AS ").append(enclose(column)).append(", "); + } + } + + // Add non-primary key columns from the right source table + for (String column : rightSourceTableMetadata.getColumnNames()) { + if (!primaryKeyColumns.contains(column)) { + createViewSql.append("t2.").append(enclose(column)); + createViewSql.append(" AS ").append(enclose(column)).append(", "); + } + } + + // Remove trailing comma and space + createViewSql.setLength(createViewSql.length() - 2); + + // Add FROM clause + createViewSql.append(" FROM "); + createViewSql.append(encloseFullTableName(leftSourceNamespace, leftSourceTable)); + createViewSql.append(" t1 "); + + // Add JOIN type based on joinType parameter + switch (joinType) { + case INNER: + createViewSql.append("INNER JOIN "); + break; + case LEFT_OUTER: + createViewSql.append("LEFT OUTER JOIN "); + break; + default: + throw new AssertionError("Unexpected join type: " + joinType); + } + + createViewSql.append(encloseFullTableName(rightSourceNamespace, rightSourceTable)); + createViewSql.append(" t2 ON "); + + // Add JOIN conditions for partition keys + boolean firstCondition = true; + for (String pkColumn : leftSourceTableMetadata.getPartitionKeyNames()) { + if (!firstCondition) { + createViewSql.append(" AND "); + } + createViewSql.append("t1.").append(enclose(pkColumn)); + createViewSql.append(" = "); + createViewSql.append("t2.").append(enclose(pkColumn)); + firstCondition = false; + } + + // Add JOIN conditions for clustering keys + for (String ckColumn : leftSourceTableMetadata.getClusteringKeyNames()) { + if (!firstCondition) { + createViewSql.append(" AND "); + } + createViewSql.append("t1.").append(enclose(ckColumn)); + createViewSql.append(" = "); + createViewSql.append("t2.").append(enclose(ckColumn)); + firstCondition = false; + } + + execute(connection, createViewSql.toString()); + } + + private void dropVirtualTableView(Connection connection, String namespace, String table) + throws SQLException { + String dropViewStatement = "DROP VIEW " + encloseFullTableName(namespace, table); + execute(connection, dropViewStatement); + } + + @Override + public Optional getVirtualTableInfo(String namespace, String table) + throws ExecutionException { + try (Connection connection = dataSource.getConnection()) { + rdbEngine.setConnectionToReadOnly(connection, true); + return Optional.ofNullable( + virtualTableMetadataService.getVirtualTableInfo(connection, namespace, table)); + } catch (SQLException e) { + throw new ExecutionException( + "Getting virtual table info for the " + + getFullTableName(namespace, table) + + " table failed", + e); + } + } + + private void throwIfVirtualTableOrSourceTable( + Connection connection, String namespace, String table, String method) throws SQLException { + VirtualTableInfo virtualTableInfo = + virtualTableMetadataService.getVirtualTableInfo(connection, namespace, table); + if (virtualTableInfo != null) { + throw new UnsupportedOperationException( + "Currently, " + method + " is not supported for virtual tables and their source tables"); + } + + List virtualTableInfos = + virtualTableMetadataService.getVirtualTableInfosBySourceTable(connection, namespace, table); + if (!virtualTableInfos.isEmpty()) { + throw new UnsupportedOperationException( + "Currently, " + method + " is not supported for virtual tables and their source tables"); + } + } + @VisibleForTesting void addTableMetadata( Connection connection, diff --git a/core/src/main/java/com/scalar/db/storage/jdbc/JdbcDatabase.java b/core/src/main/java/com/scalar/db/storage/jdbc/JdbcDatabase.java index 75e7ab92c4..5411c29462 100644 --- a/core/src/main/java/com/scalar/db/storage/jdbc/JdbcDatabase.java +++ b/core/src/main/java/com/scalar/db/storage/jdbc/JdbcDatabase.java @@ -2,28 +2,48 @@ import com.google.common.annotations.VisibleForTesting; import com.google.inject.Inject; +import com.scalar.db.api.ConditionBuilder; +import com.scalar.db.api.ConditionalExpression; import com.scalar.db.api.Delete; +import com.scalar.db.api.DeleteBuilder; +import com.scalar.db.api.DeleteIf; +import com.scalar.db.api.DeleteIfExists; import com.scalar.db.api.DistributedStorage; import com.scalar.db.api.Get; import com.scalar.db.api.Mutation; +import com.scalar.db.api.MutationCondition; +import com.scalar.db.api.Operation; import com.scalar.db.api.Put; +import com.scalar.db.api.PutBuilder; +import com.scalar.db.api.PutIf; +import com.scalar.db.api.PutIfExists; +import com.scalar.db.api.PutIfNotExists; import com.scalar.db.api.Result; import com.scalar.db.api.Scan; import com.scalar.db.api.Scanner; +import com.scalar.db.api.TableMetadata; +import com.scalar.db.api.VirtualTableInfo; +import com.scalar.db.api.VirtualTableJoinType; import com.scalar.db.common.AbstractDistributedStorage; import com.scalar.db.common.CoreError; import com.scalar.db.common.StorageInfoProvider; import com.scalar.db.common.TableMetadataManager; +import com.scalar.db.common.VirtualTableInfoManager; import com.scalar.db.common.checker.OperationChecker; import com.scalar.db.config.DatabaseConfig; import com.scalar.db.exception.storage.ExecutionException; import com.scalar.db.exception.storage.NoMutationException; import com.scalar.db.exception.storage.RetriableExecutionException; +import com.scalar.db.io.Column; import java.sql.Connection; import java.sql.SQLException; +import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.Map; import java.util.Optional; +import javax.annotation.Nullable; import javax.annotation.concurrent.ThreadSafe; import org.apache.commons.dbcp2.BasicDataSource; import org.slf4j.Logger; @@ -41,9 +61,11 @@ public class JdbcDatabase extends AbstractDistributedStorage { private static final Logger logger = LoggerFactory.getLogger(JdbcDatabase.class); + private final RdbEngineStrategy rdbEngine; private final BasicDataSource dataSource; private final BasicDataSource tableMetadataDataSource; - private final RdbEngineStrategy rdbEngine; + private final TableMetadataManager tableMetadataManager; + private final VirtualTableInfoManager virtualTableInfoManager; private final JdbcService jdbcService; @Inject @@ -56,7 +78,7 @@ public JdbcDatabase(DatabaseConfig databaseConfig) { tableMetadataDataSource = JdbcUtils.initDataSourceForTableMetadata(config, rdbEngine); JdbcAdmin jdbcAdmin = new JdbcAdmin(tableMetadataDataSource, config); - TableMetadataManager tableMetadataManager = + tableMetadataManager = new TableMetadataManager(jdbcAdmin, databaseConfig.getMetadataCacheExpirationTimeSecs()); OperationChecker operationChecker = new JdbcOperationChecker( @@ -65,20 +87,27 @@ public JdbcDatabase(DatabaseConfig databaseConfig) { jdbcService = new JdbcService( tableMetadataManager, operationChecker, rdbEngine, databaseConfig.getScanFetchSize()); + + virtualTableInfoManager = + new VirtualTableInfoManager(jdbcAdmin, databaseConfig.getMetadataCacheExpirationTimeSecs()); } @VisibleForTesting JdbcDatabase( DatabaseConfig databaseConfig, + RdbEngineStrategy rdbEngine, BasicDataSource dataSource, BasicDataSource tableMetadataDataSource, - RdbEngineStrategy rdbEngine, + TableMetadataManager tableMetadataManager, + VirtualTableInfoManager virtualTableInfoManager, JdbcService jdbcService) { super(databaseConfig); this.dataSource = dataSource; this.tableMetadataDataSource = tableMetadataDataSource; this.jdbcService = jdbcService; this.rdbEngine = rdbEngine; + this.tableMetadataManager = tableMetadataManager; + this.virtualTableInfoManager = virtualTableInfoManager; } @Override @@ -135,6 +164,16 @@ public Scanner scan(Scan scan) throws ExecutionException { @Override public void put(Put put) throws ExecutionException { put = copyAndSetTargetToIfNot(put); + + VirtualTableInfo virtualTableInfo = getVirtualTableInfo(put); + if (virtualTableInfo != null) { + // For a virtual table + List dividedPuts = dividePutForSourceTables(put, virtualTableInfo); + mutateInternal(dividedPuts); + return; + } + + // For a regular table Connection connection = null; try { connection = dataSource.getConnection(); @@ -158,6 +197,15 @@ public void put(List puts) throws ExecutionException { @Override public void delete(Delete delete) throws ExecutionException { delete = copyAndSetTargetToIfNot(delete); + + VirtualTableInfo virtualTableInfo = getVirtualTableInfo(delete); + if (virtualTableInfo != null) { + // For a virtual table + List dividedDeletes = divideDeleteForSourceTables(delete, virtualTableInfo); + mutateInternal(dividedDeletes); + return; + } + Connection connection = null; try { connection = dataSource.getConnection(); @@ -192,6 +240,32 @@ public void mutate(List mutations) throws ExecutionException } mutations = copyAndSetTargetToIfNot(mutations); + + List convertedMutations = new ArrayList<>(); + for (Mutation mutation : mutations) { + VirtualTableInfo virtualTableInfo = getVirtualTableInfo(mutation); + if (virtualTableInfo != null) { + // For a virtual table + if (mutation instanceof Put) { + Put put = (Put) mutation; + List dividedPuts = dividePutForSourceTables(put, virtualTableInfo); + convertedMutations.addAll(dividedPuts); + } else { + assert mutation instanceof Delete; + Delete delete = (Delete) mutation; + List dividedDeletes = divideDeleteForSourceTables(delete, virtualTableInfo); + convertedMutations.addAll(dividedDeletes); + } + } else { + // For a regular table + convertedMutations.add(mutation); + } + } + + mutateInternal(convertedMutations); + } + + private void mutateInternal(List mutations) throws ExecutionException { Connection connection = null; try { connection = dataSource.getConnection(); @@ -238,6 +312,168 @@ public void mutate(List mutations) throws ExecutionException } } + @Nullable + private VirtualTableInfo getVirtualTableInfo(Operation operation) throws ExecutionException { + assert operation.forNamespace().isPresent() && operation.forTable().isPresent(); + return virtualTableInfoManager.getVirtualTableInfo( + operation.forNamespace().get(), operation.forTable().get()); + } + + private List dividePutForSourceTables(Put put, VirtualTableInfo virtualTableInfo) + throws ExecutionException { + TableMetadata leftSourceTableMetadata = + tableMetadataManager.getTableMetadata( + virtualTableInfo.getLeftSourceNamespaceName(), + virtualTableInfo.getLeftSourceTableName()); + TableMetadata rightSourceTableMetadata = + tableMetadataManager.getTableMetadata( + virtualTableInfo.getRightSourceNamespaceName(), + virtualTableInfo.getRightSourceTableName()); + assert leftSourceTableMetadata != null && rightSourceTableMetadata != null; + + Map> columns = put.getColumns(); + + // Build Put for the left source table + PutBuilder.BuildableFromExisting putBuilderForLeftSourceTable = + Put.newBuilder(put) + .namespace(virtualTableInfo.getLeftSourceNamespaceName()) + .table(virtualTableInfo.getLeftSourceTableName()) + .clearValues() + .clearCondition(); + + // Add columns that belong to the left source table + for (String columnName : leftSourceTableMetadata.getColumnNames()) { + if (columns.containsKey(columnName)) { + putBuilderForLeftSourceTable.value(columns.get(columnName)); + } + } + + // Build Put for the right source table + PutBuilder.BuildableFromExisting putBuilderForRightSourceTable = + Put.newBuilder(put) + .namespace(virtualTableInfo.getRightSourceNamespaceName()) + .table(virtualTableInfo.getRightSourceTableName()) + .clearValues() + .clearCondition(); + + // Add columns that belong to the right source table + for (String columnName : rightSourceTableMetadata.getColumnNames()) { + if (columns.containsKey(columnName)) { + putBuilderForRightSourceTable.value(columns.get(columnName)); + } + } + + // Handle conditions + if (put.getCondition().isPresent()) { + MutationCondition condition = put.getCondition().get(); + if (condition instanceof PutIfExists || condition instanceof PutIfNotExists) { + // For PutIfExists/PutIfNotExists, apply based on the join type + if (virtualTableInfo.getJoinType() == VirtualTableJoinType.INNER) { + // For INNER join, apply the condition to both source tables + putBuilderForLeftSourceTable.condition(condition); + putBuilderForRightSourceTable.condition(condition); + } else { + // For LEFT_OUTER join, apply the condition only to the left source table + putBuilderForLeftSourceTable.condition(condition); + } + } else if (condition instanceof PutIf) { + // For PutIf, divide the conditional expressions based on which table the columns belong to + PutIf putIf = (PutIf) condition; + List leftExpressions = new ArrayList<>(); + List rightExpressions = new ArrayList<>(); + + for (ConditionalExpression expression : putIf.getExpressions()) { + String columnName = expression.getColumn().getName(); + if (leftSourceTableMetadata.getColumnNames().contains(columnName)) { + leftExpressions.add(expression); + } else if (rightSourceTableMetadata.getColumnNames().contains(columnName)) { + rightExpressions.add(expression); + } + } + + if (!leftExpressions.isEmpty()) { + putBuilderForLeftSourceTable.condition(ConditionBuilder.putIf(leftExpressions)); + } + if (!rightExpressions.isEmpty()) { + putBuilderForRightSourceTable.condition(ConditionBuilder.putIf(rightExpressions)); + } + } + } + + Put putForLeftSourceTable = putBuilderForLeftSourceTable.build(); + Put putForRightSourceTable = putBuilderForRightSourceTable.build(); + return Arrays.asList(putForLeftSourceTable, putForRightSourceTable); + } + + private List divideDeleteForSourceTables(Delete delete, VirtualTableInfo virtualTableInfo) + throws ExecutionException { + TableMetadata leftSourceTableMetadata = + tableMetadataManager.getTableMetadata( + virtualTableInfo.getLeftSourceNamespaceName(), + virtualTableInfo.getLeftSourceTableName()); + TableMetadata rightSourceTableMetadata = + tableMetadataManager.getTableMetadata( + virtualTableInfo.getRightSourceNamespaceName(), + virtualTableInfo.getRightSourceTableName()); + assert leftSourceTableMetadata != null && rightSourceTableMetadata != null; + + // Build Delete for the left source table + DeleteBuilder.BuildableFromExisting deleteBuilderForLeftSourceTable = + Delete.newBuilder(delete) + .namespace(virtualTableInfo.getLeftSourceNamespaceName()) + .table(virtualTableInfo.getLeftSourceTableName()) + .clearCondition(); + + // Build Delete for the right source table + DeleteBuilder.BuildableFromExisting deleteBuilderForRightSourceTable = + Delete.newBuilder(delete) + .namespace(virtualTableInfo.getRightSourceNamespaceName()) + .table(virtualTableInfo.getRightSourceTableName()) + .clearCondition(); + + // Handle conditions + if (delete.getCondition().isPresent()) { + MutationCondition condition = delete.getCondition().get(); + if (condition instanceof DeleteIfExists) { + // For DeleteIfExists, apply based on the join type + if (virtualTableInfo.getJoinType() == VirtualTableJoinType.INNER) { + // For INNER join, apply the condition to both source tables + deleteBuilderForLeftSourceTable.condition(condition); + deleteBuilderForRightSourceTable.condition(condition); + } else { + // For LEFT_OUTER join, apply the condition only to the left source table + deleteBuilderForLeftSourceTable.condition(condition); + } + } else if (condition instanceof DeleteIf) { + // For DeleteIf, divide the conditional expressions based on which table the columns belong + // to + DeleteIf deleteIf = (DeleteIf) condition; + List leftExpressions = new ArrayList<>(); + List rightExpressions = new ArrayList<>(); + + for (ConditionalExpression expression : deleteIf.getExpressions()) { + String columnName = expression.getColumn().getName(); + if (leftSourceTableMetadata.getColumnNames().contains(columnName)) { + leftExpressions.add(expression); + } else if (rightSourceTableMetadata.getColumnNames().contains(columnName)) { + rightExpressions.add(expression); + } + } + + if (!leftExpressions.isEmpty()) { + deleteBuilderForLeftSourceTable.condition(ConditionBuilder.deleteIf(leftExpressions)); + } + if (!rightExpressions.isEmpty()) { + deleteBuilderForRightSourceTable.condition(ConditionBuilder.deleteIf(rightExpressions)); + } + } + } + + Delete deleteForLeftSourceTable = deleteBuilderForLeftSourceTable.build(); + Delete deleteForRightSourceTable = deleteBuilderForRightSourceTable.build(); + return Arrays.asList(deleteForLeftSourceTable, deleteForRightSourceTable); + } + private void close(Connection connection) { try { if (connection != null) { diff --git a/core/src/main/java/com/scalar/db/storage/jdbc/RdbEngineSqlite.java b/core/src/main/java/com/scalar/db/storage/jdbc/RdbEngineSqlite.java index af8366c3c3..11efe2c315 100644 --- a/core/src/main/java/com/scalar/db/storage/jdbc/RdbEngineSqlite.java +++ b/core/src/main/java/com/scalar/db/storage/jdbc/RdbEngineSqlite.java @@ -72,7 +72,11 @@ public boolean isUndefinedTableError(SQLException e) { // Error code: SQLITE_ERROR (1) // Message: SQL error or missing database (no such table: XXX) - return e.getErrorCode() == 1 && e.getMessage().contains("no such table:"); + // Error code: SQLITE_SCHEMA (17) + // Message: The database schema changed (no such table: XXX) + + return (e.getErrorCode() == 1 || e.getErrorCode() == 17) + && e.getMessage().contains("no such table:"); } @Override diff --git a/core/src/main/java/com/scalar/db/storage/jdbc/TableMetadataService.java b/core/src/main/java/com/scalar/db/storage/jdbc/TableMetadataService.java index dfffc6137a..37a3267986 100644 --- a/core/src/main/java/com/scalar/db/storage/jdbc/TableMetadataService.java +++ b/core/src/main/java/com/scalar/db/storage/jdbc/TableMetadataService.java @@ -203,41 +203,39 @@ TableMetadata getTableMetadata(Connection connection, String namespace, String t TableMetadata.Builder builder = TableMetadata.newBuilder(); boolean tableExists = false; - try { - try (PreparedStatement preparedStatement = - connection.prepareStatement(getSelectColumnsStatement())) { - preparedStatement.setString(1, getFullTableName(namespace, table)); - - try (ResultSet resultSet = preparedStatement.executeQuery()) { - while (resultSet.next()) { - tableExists = true; - - String columnName = resultSet.getString(COL_COLUMN_NAME); - DataType dataType = DataType.valueOf(resultSet.getString(COL_DATA_TYPE)); - builder.addColumn(columnName, dataType); - - boolean indexed = resultSet.getBoolean(COL_INDEXED); - if (indexed) { - builder.addSecondaryIndex(columnName); - } - - String keyType = resultSet.getString(COL_KEY_TYPE); - if (keyType == null) { - continue; - } - - switch (KeyType.valueOf(keyType)) { - case PARTITION: - builder.addPartitionKey(columnName); - break; - case CLUSTERING: - Scan.Ordering.Order clusteringOrder = - Scan.Ordering.Order.valueOf(resultSet.getString(COL_CLUSTERING_ORDER)); - builder.addClusteringKey(columnName, clusteringOrder); - break; - default: - throw new AssertionError("Invalid key type: " + keyType); - } + try (PreparedStatement preparedStatement = + connection.prepareStatement(getSelectColumnsStatement())) { + preparedStatement.setString(1, getFullTableName(namespace, table)); + + try (ResultSet resultSet = preparedStatement.executeQuery()) { + while (resultSet.next()) { + tableExists = true; + + String columnName = resultSet.getString(COL_COLUMN_NAME); + DataType dataType = DataType.valueOf(resultSet.getString(COL_DATA_TYPE)); + builder.addColumn(columnName, dataType); + + boolean indexed = resultSet.getBoolean(COL_INDEXED); + if (indexed) { + builder.addSecondaryIndex(columnName); + } + + String keyType = resultSet.getString(COL_KEY_TYPE); + if (keyType == null) { + continue; + } + + switch (KeyType.valueOf(keyType)) { + case PARTITION: + builder.addPartitionKey(columnName); + break; + case CLUSTERING: + Scan.Ordering.Order clusteringOrder = + Scan.Ordering.Order.valueOf(resultSet.getString(COL_CLUSTERING_ORDER)); + builder.addClusteringKey(columnName, clusteringOrder); + break; + default: + throw new AssertionError("Invalid key type: " + keyType); } } } @@ -308,21 +306,18 @@ Set getNamespaceTableNames(Connection connection, String namespace) thro + " WHERE " + enclose(COL_FULL_TABLE_NAME) + " LIKE ?"; - try { - try (PreparedStatement preparedStatement = - connection.prepareStatement(selectTablesOfNamespaceStatement)) { - String prefix = namespace + "."; - preparedStatement.setString(1, prefix + "%"); - try (ResultSet results = preparedStatement.executeQuery()) { - Set tableNames = new HashSet<>(); - while (results.next()) { - String tableName = results.getString(COL_FULL_TABLE_NAME).substring(prefix.length()); - tableNames.add(tableName); - } - return tableNames; + try (PreparedStatement preparedStatement = + connection.prepareStatement(selectTablesOfNamespaceStatement)) { + String prefix = namespace + "."; + preparedStatement.setString(1, prefix + "%"); + try (ResultSet results = preparedStatement.executeQuery()) { + Set tableNames = new HashSet<>(); + while (results.next()) { + String tableName = results.getString(COL_FULL_TABLE_NAME).substring(prefix.length()); + tableNames.add(tableName); } + return tableNames; } - } catch (SQLException e) { // An exception will be thrown if the metadata table does not exist when executing the select // query diff --git a/core/src/main/java/com/scalar/db/storage/jdbc/VirtualTableMetadataService.java b/core/src/main/java/com/scalar/db/storage/jdbc/VirtualTableMetadataService.java new file mode 100644 index 0000000000..329d811e4f --- /dev/null +++ b/core/src/main/java/com/scalar/db/storage/jdbc/VirtualTableMetadataService.java @@ -0,0 +1,333 @@ +package com.scalar.db.storage.jdbc; + +import static com.scalar.db.storage.jdbc.JdbcAdmin.execute; +import static com.scalar.db.util.ScalarDbUtils.getFullTableName; + +import com.scalar.db.api.VirtualTableInfo; +import com.scalar.db.api.VirtualTableJoinType; +import com.scalar.db.io.DataType; +import edu.umd.cs.findbugs.annotations.Nullable; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +@SuppressFBWarnings("OBL_UNSATISFIED_OBLIGATION") +public class VirtualTableMetadataService { + private static final String TABLE_NAME = "virtual_tables"; + private static final String COL_FULL_TABLE_NAME = "full_table_name"; + private static final String COL_LEFT_SOURCE_TABLE_FULL_TABLE_NAME = + "left_source_table_full_table_name"; + private static final String COL_RIGHT_SOURCE_TABLE_FULL_TABLE_NAME = + "right_source_table_full_table_name"; + private static final String COL_JOIN_TYPE = "join_type"; + private static final String COL_ATTRIBUTES = "attributes"; + + private final String metadataSchema; + private final RdbEngineStrategy rdbEngine; + + VirtualTableMetadataService(String metadataSchema, RdbEngineStrategy rdbEngine) { + this.metadataSchema = metadataSchema; + this.rdbEngine = rdbEngine; + } + + void createVirtualTablesTableIfNotExists(Connection connection) throws SQLException { + String createTableStatement = + "CREATE TABLE " + + encloseFullTableName(metadataSchema, TABLE_NAME) + + "(" + + enclose(COL_FULL_TABLE_NAME) + + " " + + getTextType(128, true) + + ", " + + enclose(COL_LEFT_SOURCE_TABLE_FULL_TABLE_NAME) + + " " + + getTextType(128, true) + + ", " + + enclose(COL_RIGHT_SOURCE_TABLE_FULL_TABLE_NAME) + + " " + + getTextType(128, true) + + ", " + + enclose(COL_JOIN_TYPE) + + " " + + getTextType(20, false) + + ", " + + enclose(COL_ATTRIBUTES) + + " " + + rdbEngine.getDataTypeForEngine(DataType.TEXT) + + ", " + + "PRIMARY KEY (" + + enclose(COL_FULL_TABLE_NAME) + + "))"; + createTable(connection, createTableStatement, true); + } + + void deleteVirtualTablesTableIfEmpty(Connection connection) throws SQLException { + if (isVirtualTablesTableEmpty(connection)) { + deleteTable(connection, encloseFullTableName(metadataSchema, TABLE_NAME)); + } + } + + private boolean isVirtualTablesTableEmpty(Connection connection) throws SQLException { + String selectAllTables = "SELECT * FROM " + encloseFullTableName(metadataSchema, TABLE_NAME); + try (Statement statement = connection.createStatement(); + ResultSet results = statement.executeQuery(selectAllTables)) { + return !results.next(); + } + } + + void insertIntoVirtualTablesTable( + Connection connection, + String namespace, + String table, + String leftSourceNamespace, + String leftSourceTable, + String rightSourceNamespace, + String rightSourceTable, + VirtualTableJoinType joinType, + String attributes) + throws SQLException { + String insertStatement = + "INSERT INTO " + encloseFullTableName(metadataSchema, TABLE_NAME) + " VALUES (?,?,?,?,?)"; + try (PreparedStatement preparedStatement = connection.prepareStatement(insertStatement)) { + preparedStatement.setString(1, getFullTableName(namespace, table)); + preparedStatement.setString(2, getFullTableName(leftSourceNamespace, leftSourceTable)); + preparedStatement.setString(3, getFullTableName(rightSourceNamespace, rightSourceTable)); + preparedStatement.setString(4, joinType.name()); + preparedStatement.setString(5, attributes); + preparedStatement.execute(); + } + } + + void deleteFromVirtualTablesTable(Connection connection, String namespace, String table) + throws SQLException { + String deleteStatement = + "DELETE FROM " + + encloseFullTableName(metadataSchema, TABLE_NAME) + + " WHERE " + + enclose(COL_FULL_TABLE_NAME) + + " = ?"; + try (PreparedStatement preparedStatement = connection.prepareStatement(deleteStatement)) { + preparedStatement.setString(1, getFullTableName(namespace, table)); + preparedStatement.execute(); + } + } + + @Nullable + VirtualTableInfo getVirtualTableInfo(Connection connection, String namespace, String table) + throws SQLException { + if (!internalTableExists(connection, metadataSchema, TABLE_NAME)) { + return null; + } + + String selectStatement = + "SELECT * FROM " + + encloseFullTableName(metadataSchema, TABLE_NAME) + + " WHERE " + + enclose(COL_FULL_TABLE_NAME) + + " = ?"; + try (PreparedStatement preparedStatement = connection.prepareStatement(selectStatement)) { + preparedStatement.setString(1, getFullTableName(namespace, table)); + try (ResultSet results = preparedStatement.executeQuery()) { + if (results.next()) { + return createVirtualTableInfoFromResultSet(results); + } + + return null; + } + } + } + + List getVirtualTableInfosBySourceTable( + Connection connection, String sourceNamespace, String sourceTable) throws SQLException { + if (!internalTableExists(connection, metadataSchema, TABLE_NAME)) { + return new ArrayList<>(); + } + + String selectStatement = + "SELECT * FROM " + + encloseFullTableName(metadataSchema, TABLE_NAME) + + " WHERE " + + enclose(COL_LEFT_SOURCE_TABLE_FULL_TABLE_NAME) + + " = ? OR " + + enclose(COL_RIGHT_SOURCE_TABLE_FULL_TABLE_NAME) + + " = ?"; + try (PreparedStatement preparedStatement = connection.prepareStatement(selectStatement)) { + String sourceTableFullTableName = getFullTableName(sourceNamespace, sourceTable); + preparedStatement.setString(1, sourceTableFullTableName); + preparedStatement.setString(2, sourceTableFullTableName); + try (ResultSet results = preparedStatement.executeQuery()) { + List ret = new ArrayList<>(); + while (results.next()) { + ret.add(createVirtualTableInfoFromResultSet(results)); + } + return ret; + } + } + } + + private VirtualTableInfo createVirtualTableInfoFromResultSet(ResultSet results) + throws SQLException { + String fullTableName = results.getString(COL_FULL_TABLE_NAME); + String namespace = fullTableName.substring(0, fullTableName.indexOf('.')); + String table = fullTableName.substring(fullTableName.indexOf('.') + 1); + String leftSourceTableFullTableName = results.getString(COL_LEFT_SOURCE_TABLE_FULL_TABLE_NAME); + String rightSourceTableFullTableName = + results.getString(COL_RIGHT_SOURCE_TABLE_FULL_TABLE_NAME); + VirtualTableJoinType joinType = VirtualTableJoinType.valueOf(results.getString(COL_JOIN_TYPE)); + + String leftSourceNamespace = + leftSourceTableFullTableName.substring(0, leftSourceTableFullTableName.indexOf('.')); + String leftSourceTable = + leftSourceTableFullTableName.substring(leftSourceTableFullTableName.indexOf('.') + 1); + String rightSourceNamespace = + rightSourceTableFullTableName.substring(0, rightSourceTableFullTableName.indexOf('.')); + String rightSourceTable = + rightSourceTableFullTableName.substring(rightSourceTableFullTableName.indexOf('.') + 1); + + return new VirtualTableInfo() { + @Override + public String getNamespaceName() { + return namespace; + } + + @Override + public String getTableName() { + return table; + } + + @Override + public String getLeftSourceNamespaceName() { + return leftSourceNamespace; + } + + @Override + public String getLeftSourceTableName() { + return leftSourceTable; + } + + @Override + public String getRightSourceNamespaceName() { + return rightSourceNamespace; + } + + @Override + public String getRightSourceTableName() { + return rightSourceTable; + } + + @Override + public VirtualTableJoinType getJoinType() { + return joinType; + } + }; + } + + Set getNamespaceTableNames(Connection connection, String namespace) throws SQLException { + if (!internalTableExists(connection, metadataSchema, TABLE_NAME)) { + return Collections.emptySet(); + } + + String selectTablesOfNamespaceStatement = + "SELECT " + + enclose(COL_FULL_TABLE_NAME) + + " FROM " + + encloseFullTableName(metadataSchema, TABLE_NAME) + + " WHERE " + + enclose(COL_FULL_TABLE_NAME) + + " LIKE ?"; + try (PreparedStatement preparedStatement = + connection.prepareStatement(selectTablesOfNamespaceStatement)) { + String prefix = namespace + "."; + preparedStatement.setString(1, prefix + "%"); + try (ResultSet results = preparedStatement.executeQuery()) { + Set tableNames = new HashSet<>(); + while (results.next()) { + String tableName = results.getString(COL_FULL_TABLE_NAME).substring(prefix.length()); + tableNames.add(tableName); + } + return tableNames; + } + } + } + + Set getNamespaceNamesOfExistingTables(Connection connection) throws SQLException { + if (!internalTableExists(connection, metadataSchema, TABLE_NAME)) { + return Collections.emptySet(); + } + + String selectAllTableNames = + "SELECT " + + enclose(COL_FULL_TABLE_NAME) + + " FROM " + + encloseFullTableName(metadataSchema, TABLE_NAME); + try (Statement stmt = connection.createStatement(); + ResultSet rs = stmt.executeQuery(selectAllTableNames)) { + Set namespaceOfExistingTables = new HashSet<>(); + while (rs.next()) { + String fullTableName = rs.getString(COL_FULL_TABLE_NAME); + String namespaceName = fullTableName.substring(0, fullTableName.indexOf('.')); + namespaceOfExistingTables.add(namespaceName); + } + return namespaceOfExistingTables; + } + } + + private String getTextType(int charLength, boolean isKey) { + return rdbEngine.getTextType(charLength, isKey); + } + + private void createTable(Connection connection, String createTableStatement, boolean ifNotExists) + throws SQLException { + String stmt = createTableStatement; + if (ifNotExists) { + stmt = rdbEngine.tryAddIfNotExistsToCreateTableSql(createTableStatement); + } + try { + execute(connection, stmt); + } catch (SQLException e) { + // Suppress the exception thrown when the table already exists + if (!(ifNotExists && rdbEngine.isDuplicateTableError(e))) { + throw e; + } + } + } + + private boolean internalTableExists(Connection connection, String namespace, String table) + throws SQLException { + String fullTableName = encloseFullTableName(namespace, table); + String sql = rdbEngine.internalTableExistsCheckSql(fullTableName); + try { + execute(connection, sql); + return true; + } catch (SQLException e) { + // An exception will be thrown if the table does not exist when executing the select + // query + if (rdbEngine.isUndefinedTableError(e)) { + return false; + } + throw e; + } + } + + private void deleteTable(Connection connection, String fullTableName) throws SQLException { + String dropTableStatement = "DROP TABLE " + fullTableName; + + execute(connection, dropTableStatement); + } + + private String enclose(String name) { + return rdbEngine.enclose(name); + } + + private String encloseFullTableName(String schema, String table) { + return rdbEngine.encloseFullTableName(schema, table); + } +} diff --git a/core/src/main/java/com/scalar/db/storage/multistorage/MultiStorageAdmin.java b/core/src/main/java/com/scalar/db/storage/multistorage/MultiStorageAdmin.java index 66579b59d9..8581361c7e 100644 --- a/core/src/main/java/com/scalar/db/storage/multistorage/MultiStorageAdmin.java +++ b/core/src/main/java/com/scalar/db/storage/multistorage/MultiStorageAdmin.java @@ -5,16 +5,21 @@ import com.scalar.db.api.DistributedStorageAdmin; import com.scalar.db.api.StorageInfo; import com.scalar.db.api.TableMetadata; +import com.scalar.db.api.VirtualTableInfo; +import com.scalar.db.api.VirtualTableJoinType; +import com.scalar.db.common.CoreError; import com.scalar.db.common.StorageInfoImpl; import com.scalar.db.config.DatabaseConfig; import com.scalar.db.exception.storage.ExecutionException; import com.scalar.db.io.DataType; import com.scalar.db.service.StorageFactory; +import com.scalar.db.util.ScalarDbUtils; import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; import javax.annotation.concurrent.ThreadSafe; @@ -288,6 +293,54 @@ public StorageInfo getStorageInfo(String namespace) throws ExecutionException { } } + @Override + public void createVirtualTable( + String namespace, + String table, + String leftSourceNamespace, + String leftSourceTable, + String rightSourceNamespace, + String rightSourceTable, + VirtualTableJoinType joinType, + Map options) + throws ExecutionException { + StorageInfo storageInfo = getStorageInfo(namespace); + StorageInfo storageInfoForLeftSourceNamespace = getStorageInfo(leftSourceNamespace); + StorageInfo storageInfoForRightSourceNamespace = getStorageInfo(rightSourceNamespace); + if (!storageInfo.getStorageName().equals(storageInfoForLeftSourceNamespace.getStorageName())) { + throw new IllegalArgumentException( + CoreError.VIRTUAL_TABLE_IN_DIFFERENT_STORAGE_FROM_SOURCE_TABLES.buildMessage( + ScalarDbUtils.getFullTableName(namespace, table), + ScalarDbUtils.getFullTableName(leftSourceNamespace, leftSourceTable), + ScalarDbUtils.getFullTableName(rightSourceNamespace, rightSourceTable))); + } + if (!storageInfoForLeftSourceNamespace + .getStorageName() + .equals(storageInfoForRightSourceNamespace.getStorageName())) { + throw new IllegalArgumentException( + CoreError.VIRTUAL_TABLE_SOURCE_TABLES_IN_DIFFERENT_STORAGES.buildMessage( + ScalarDbUtils.getFullTableName(leftSourceNamespace, leftSourceTable), + ScalarDbUtils.getFullTableName(rightSourceNamespace, rightSourceTable))); + } + + getAdmin(namespace) + .createVirtualTable( + namespace, + table, + leftSourceNamespace, + leftSourceTable, + rightSourceNamespace, + rightSourceTable, + joinType, + options); + } + + @Override + public Optional getVirtualTableInfo(String namespace, String table) + throws ExecutionException { + return getAdmin(namespace, table).getVirtualTableInfo(namespace, table); + } + private AdminHolder getAdminHolder(String namespace) { AdminHolder adminHolder = namespaceAdminMap.get(namespace); if (adminHolder != null) { diff --git a/core/src/main/java/com/scalar/db/storage/objectstorage/ObjectStorageAdmin.java b/core/src/main/java/com/scalar/db/storage/objectstorage/ObjectStorageAdmin.java index 594cbcefe7..6e3c6289fb 100644 --- a/core/src/main/java/com/scalar/db/storage/objectstorage/ObjectStorageAdmin.java +++ b/core/src/main/java/com/scalar/db/storage/objectstorage/ObjectStorageAdmin.java @@ -6,6 +6,8 @@ import com.scalar.db.api.DistributedStorageAdmin; import com.scalar.db.api.StorageInfo; import com.scalar.db.api.TableMetadata; +import com.scalar.db.api.VirtualTableInfo; +import com.scalar.db.api.VirtualTableJoinType; import com.scalar.db.common.CoreError; import com.scalar.db.common.StorageInfoImpl; import com.scalar.db.config.DatabaseConfig; @@ -366,6 +368,25 @@ public Set getNamespaceNames() throws ExecutionException { } } + @Override + public void createVirtualTable( + String namespace, + String table, + String leftSourceNamespace, + String leftSourceTable, + String rightSourceNamespace, + String rightSourceTable, + VirtualTableJoinType joinType, + Map options) { + throw new AssertionError("CommonDistributedStorageAdmin should not call this method"); + } + + @Override + public Optional getVirtualTableInfo(String namespace, String table) { + // Virtual tables are not supported. + return Optional.empty(); + } + private Map getNamespaceMetadataTable() throws ExecutionException { return getNamespaceMetadataTable(null); diff --git a/core/src/test/java/com/scalar/db/common/CommonDistributedStorageAdminTest.java b/core/src/test/java/com/scalar/db/common/CommonDistributedStorageAdminTest.java index 127b8517cc..b8404698b9 100644 --- a/core/src/test/java/com/scalar/db/common/CommonDistributedStorageAdminTest.java +++ b/core/src/test/java/com/scalar/db/common/CommonDistributedStorageAdminTest.java @@ -1,5 +1,6 @@ package com.scalar.db.common; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; @@ -7,10 +8,18 @@ import com.google.common.collect.ImmutableMap; import com.scalar.db.api.DistributedStorageAdmin; +import com.scalar.db.api.Scan.Ordering.Order; +import com.scalar.db.api.StorageInfo; import com.scalar.db.api.TableMetadata; +import com.scalar.db.api.VirtualTableInfo; +import com.scalar.db.api.VirtualTableJoinType; import com.scalar.db.exception.storage.ExecutionException; +import com.scalar.db.io.DataType; +import java.util.Arrays; import java.util.Collections; +import java.util.HashSet; import java.util.Map; +import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.Mock; @@ -107,4 +116,357 @@ public void repairTable_ShouldCallAdminProperly() throws ExecutionException { namespaceName, tableName, tableMetadata, options)) .isInstanceOf(UnsupportedOperationException.class); } + + @Test + public void createVirtualTable_ProperArgumentsGiven_ShouldCallAdminProperly() + throws ExecutionException { + // Arrange + String namespace = "ns"; + String table = "vtable"; + String leftSourceNamespace = "ns"; + String leftSourceTable = "left_table"; + String rightSourceNamespace = "ns"; + String rightSourceTable = "right_table"; + VirtualTableJoinType joinType = VirtualTableJoinType.INNER; + Map options = Collections.emptyMap(); + + StorageInfo storageInfo = mock(StorageInfo.class); + when(storageInfo.getStorageName()).thenReturn("test-storage"); + when(storageInfo.getMutationAtomicityUnit()) + .thenReturn(StorageInfo.MutationAtomicityUnit.NAMESPACE); + when(admin.getStorageInfo(leftSourceNamespace)).thenReturn(storageInfo); + when(admin.namespaceExists(namespace)).thenReturn(true); + + // Mock getNamespaceTableNames for tableExists() to work + // All tables (namespace, leftSourceNamespace, rightSourceNamespace) are in "ns" + // Return source tables that exist, but not the target table + when(admin.getNamespaceTableNames("ns")) + .thenReturn(new HashSet<>(Arrays.asList(leftSourceTable, rightSourceTable))); + + TableMetadata leftTableMetadata = + TableMetadata.newBuilder() + .addPartitionKey("pk1") + .addClusteringKey("ck1", Order.ASC) + .addColumn("pk1", DataType.INT) + .addColumn("ck1", DataType.TEXT) + .addColumn("col1", DataType.INT) + .build(); + when(admin.getTableMetadata(leftSourceNamespace, leftSourceTable)) + .thenReturn(leftTableMetadata); + + TableMetadata rightTableMetadata = + TableMetadata.newBuilder() + .addPartitionKey("pk1") + .addClusteringKey("ck1", Order.ASC) + .addColumn("pk1", DataType.INT) + .addColumn("ck1", DataType.TEXT) + .addColumn("col2", DataType.TEXT) + .build(); + when(admin.getTableMetadata(rightSourceNamespace, rightSourceTable)) + .thenReturn(rightTableMetadata); + + when(admin.getVirtualTableInfo(leftSourceNamespace, leftSourceTable)) + .thenReturn(Optional.empty()); + when(admin.getVirtualTableInfo(rightSourceNamespace, rightSourceTable)) + .thenReturn(Optional.empty()); + + // Act + commonDistributedStorageAdmin.createVirtualTable( + namespace, + table, + leftSourceNamespace, + leftSourceTable, + rightSourceNamespace, + rightSourceTable, + joinType, + options); + + // Assert + verify(admin) + .createVirtualTable( + namespace, + table, + leftSourceNamespace, + leftSourceTable, + rightSourceNamespace, + rightSourceTable, + joinType, + options); + } + + @Test + public void + createVirtualTable_SourceTablesWithDifferentPartitionKeys_ShouldThrowIllegalArgumentException() + throws ExecutionException { + // Arrange + String namespace = "ns"; + String table = "vtable"; + String leftSourceNamespace = "ns"; + String leftSourceTable = "left_table"; + String rightSourceNamespace = "ns"; + String rightSourceTable = "right_table"; + VirtualTableJoinType joinType = VirtualTableJoinType.INNER; + Map options = Collections.emptyMap(); + + StorageInfo storageInfo = mock(StorageInfo.class); + when(storageInfo.getMutationAtomicityUnit()) + .thenReturn(StorageInfo.MutationAtomicityUnit.NAMESPACE); + when(admin.getStorageInfo(leftSourceNamespace)).thenReturn(storageInfo); + when(admin.namespaceExists(namespace)).thenReturn(true); + when(admin.tableExists(namespace, table)).thenReturn(false); + + TableMetadata leftTableMetadata = + TableMetadata.newBuilder() + .addPartitionKey("pk1") + .addColumn("pk1", DataType.INT) + .addColumn("col1", DataType.INT) + .build(); + when(admin.getTableMetadata(leftSourceNamespace, leftSourceTable)) + .thenReturn(leftTableMetadata); + + TableMetadata rightTableMetadata = + TableMetadata.newBuilder() + .addPartitionKey("pk2") + .addColumn("pk2", DataType.INT) + .addColumn("col2", DataType.TEXT) + .build(); + when(admin.getTableMetadata(rightSourceNamespace, rightSourceTable)) + .thenReturn(rightTableMetadata); + + // Act Assert + assertThatThrownBy( + () -> + commonDistributedStorageAdmin.createVirtualTable( + namespace, + table, + leftSourceNamespace, + leftSourceTable, + rightSourceNamespace, + rightSourceTable, + joinType, + options)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("must have the same primary key"); + } + + @Test + public void + createVirtualTable_SourceTablesWithDifferentPrimaryKeyTypes_ShouldThrowIllegalArgumentException() + throws ExecutionException { + // Arrange + String namespace = "ns"; + String table = "vtable"; + String leftSourceNamespace = "ns"; + String leftSourceTable = "left_table"; + String rightSourceNamespace = "ns"; + String rightSourceTable = "right_table"; + VirtualTableJoinType joinType = VirtualTableJoinType.INNER; + Map options = Collections.emptyMap(); + + StorageInfo storageInfo = mock(StorageInfo.class); + when(storageInfo.getMutationAtomicityUnit()) + .thenReturn(StorageInfo.MutationAtomicityUnit.NAMESPACE); + when(admin.getStorageInfo(leftSourceNamespace)).thenReturn(storageInfo); + when(admin.namespaceExists(namespace)).thenReturn(true); + when(admin.tableExists(namespace, table)).thenReturn(false); + + TableMetadata leftTableMetadata = + TableMetadata.newBuilder() + .addPartitionKey("pk1") + .addColumn("pk1", DataType.INT) + .addColumn("col1", DataType.INT) + .build(); + when(admin.getTableMetadata(leftSourceNamespace, leftSourceTable)) + .thenReturn(leftTableMetadata); + + TableMetadata rightTableMetadata = + TableMetadata.newBuilder() + .addPartitionKey("pk1") + .addColumn("pk1", DataType.TEXT) + .addColumn("col2", DataType.TEXT) + .build(); + when(admin.getTableMetadata(rightSourceNamespace, rightSourceTable)) + .thenReturn(rightTableMetadata); + + // Act Assert + assertThatThrownBy( + () -> + commonDistributedStorageAdmin.createVirtualTable( + namespace, + table, + leftSourceNamespace, + leftSourceTable, + rightSourceNamespace, + rightSourceTable, + joinType, + options)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("must have the same data types for primary key column"); + } + + @Test + public void + createVirtualTable_SourceTablesWithConflictingColumnNames_ShouldThrowIllegalArgumentException() + throws ExecutionException { + // Arrange + String namespace = "ns"; + String table = "vtable"; + String leftSourceNamespace = "ns"; + String leftSourceTable = "left_table"; + String rightSourceNamespace = "ns"; + String rightSourceTable = "right_table"; + VirtualTableJoinType joinType = VirtualTableJoinType.INNER; + Map options = Collections.emptyMap(); + + StorageInfo storageInfo = mock(StorageInfo.class); + when(storageInfo.getMutationAtomicityUnit()) + .thenReturn(StorageInfo.MutationAtomicityUnit.NAMESPACE); + when(admin.getStorageInfo(leftSourceNamespace)).thenReturn(storageInfo); + when(admin.namespaceExists(namespace)).thenReturn(true); + + // Mock getNamespaceTableNames for tableExists() to work + // All tables are in "ns" namespace + when(admin.getNamespaceTableNames("ns")) + .thenReturn(new HashSet<>(Arrays.asList(leftSourceTable, rightSourceTable))); + + TableMetadata leftTableMetadata = + TableMetadata.newBuilder() + .addPartitionKey("pk1") + .addColumn("pk1", DataType.INT) + .addColumn("col1", DataType.INT) + .build(); + when(admin.getTableMetadata(leftSourceNamespace, leftSourceTable)) + .thenReturn(leftTableMetadata); + + TableMetadata rightTableMetadata = + TableMetadata.newBuilder() + .addPartitionKey("pk1") + .addColumn("pk1", DataType.INT) + .addColumn("col1", DataType.TEXT) + .build(); + when(admin.getTableMetadata(rightSourceNamespace, rightSourceTable)) + .thenReturn(rightTableMetadata); + + // Act Assert + assertThatThrownBy( + () -> + commonDistributedStorageAdmin.createVirtualTable( + namespace, + table, + leftSourceNamespace, + leftSourceTable, + rightSourceNamespace, + rightSourceTable, + joinType, + options)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("have conflicting non-key column names"); + } + + @Test + public void createVirtualTable_VirtualTableUsedAsSource_ShouldThrowIllegalArgumentException() + throws ExecutionException { + // Arrange + String namespace = "ns"; + String table = "vtable"; + String leftSourceNamespace = "ns"; + String leftSourceTable = "left_vtable"; + String rightSourceNamespace = "ns"; + String rightSourceTable = "right_table"; + VirtualTableJoinType joinType = VirtualTableJoinType.INNER; + Map options = Collections.emptyMap(); + + StorageInfo storageInfo = mock(StorageInfo.class); + when(storageInfo.getMutationAtomicityUnit()) + .thenReturn(StorageInfo.MutationAtomicityUnit.NAMESPACE); + when(admin.getStorageInfo(leftSourceNamespace)).thenReturn(storageInfo); + when(admin.namespaceExists(namespace)).thenReturn(true); + + // Mock getNamespaceTableNames for tableExists() to work + // All tables are in "ns" namespace + when(admin.getNamespaceTableNames("ns")) + .thenReturn(new HashSet<>(Arrays.asList(leftSourceTable, rightSourceTable))); + + TableMetadata leftTableMetadata = + TableMetadata.newBuilder() + .addPartitionKey("pk1") + .addColumn("pk1", DataType.INT) + .addColumn("col1", DataType.INT) + .build(); + when(admin.getTableMetadata(leftSourceNamespace, leftSourceTable)) + .thenReturn(leftTableMetadata); + + TableMetadata rightTableMetadata = + TableMetadata.newBuilder() + .addPartitionKey("pk1") + .addColumn("pk1", DataType.INT) + .addColumn("col2", DataType.TEXT) + .build(); + when(admin.getTableMetadata(rightSourceNamespace, rightSourceTable)) + .thenReturn(rightTableMetadata); + + VirtualTableInfo virtualTableInfo = mock(VirtualTableInfo.class); + when(admin.getVirtualTableInfo(leftSourceNamespace, leftSourceTable)) + .thenReturn(Optional.of(virtualTableInfo)); + + // Act Assert + assertThatThrownBy( + () -> + commonDistributedStorageAdmin.createVirtualTable( + namespace, + table, + leftSourceNamespace, + leftSourceTable, + rightSourceNamespace, + rightSourceTable, + joinType, + options)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Virtual tables cannot be used as source tables"); + } + + @Test + public void getVirtualTableInfo_ProperArgumentsGiven_ShouldCallAdminProperly() + throws ExecutionException { + // Arrange + String namespace = "ns"; + String table = "vtable"; + + // Mock getNamespaceTableNames for tableExists() to work + when(admin.getNamespaceTableNames(namespace)) + .thenReturn(new HashSet<>(Collections.singletonList(table))); + + TableMetadata tableMetadata = mock(TableMetadata.class); + when(admin.getTableMetadata(namespace, table)).thenReturn(tableMetadata); + + VirtualTableInfo virtualTableInfo = mock(VirtualTableInfo.class); + when(admin.getVirtualTableInfo(namespace, table)).thenReturn(Optional.of(virtualTableInfo)); + + // Act + Optional result = + commonDistributedStorageAdmin.getVirtualTableInfo(namespace, table); + + // Assert + assertThat(result).isPresent(); + assertThat(result.get()).isEqualTo(virtualTableInfo); + verify(admin).getVirtualTableInfo(namespace, table); + } + + @Test + public void getVirtualTableInfo_TableDoesNotExist_ShouldThrowIllegalArgumentException() + throws ExecutionException { + // Arrange + String namespace = "ns"; + String table = "vtable"; + + // Mock getNamespaceTableNames for tableExists() to return false + when(admin.getNamespaceTableNames(namespace)).thenReturn(Collections.emptySet()); + + when(admin.getTableMetadata(namespace, table)).thenReturn(null); + + // Act Assert + assertThatThrownBy(() -> commonDistributedStorageAdmin.getVirtualTableInfo(namespace, table)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("does not exist"); + } } diff --git a/core/src/test/java/com/scalar/db/common/VirtualTableInfoManagerTest.java b/core/src/test/java/com/scalar/db/common/VirtualTableInfoManagerTest.java new file mode 100644 index 0000000000..f67997bbc2 --- /dev/null +++ b/core/src/test/java/com/scalar/db/common/VirtualTableInfoManagerTest.java @@ -0,0 +1,242 @@ +package com.scalar.db.common; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyString; +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.scalar.db.api.DistributedStorageAdmin; +import com.scalar.db.api.Get; +import com.scalar.db.api.Operation; +import com.scalar.db.api.VirtualTableInfo; +import com.scalar.db.api.VirtualTableJoinType; +import com.scalar.db.exception.storage.ExecutionException; +import com.scalar.db.io.Key; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +public class VirtualTableInfoManagerTest { + + @Mock private DistributedStorageAdmin admin; + + private VirtualTableInfoManager manager; + + @BeforeEach + public void setUp() throws Exception { + MockitoAnnotations.openMocks(this).close(); + } + + @Test + public void getVirtualTableInfo_WithOperation_VirtualTableExists_ShouldReturnVirtualTableInfo() + throws Exception { + // Arrange + manager = new VirtualTableInfoManager(admin, -1); + VirtualTableInfo virtualTableInfo = createVirtualTableInfo(); + when(admin.getVirtualTableInfo("ns", "table")).thenReturn(Optional.of(virtualTableInfo)); + + Operation operation = + Get.newBuilder().namespace("ns").table("table").partitionKey(Key.ofInt("pk", 1)).build(); + + // Act + VirtualTableInfo result = manager.getVirtualTableInfo(operation); + + // Assert + assertThat(result).isNotNull(); + assertThat(result.getNamespaceName()).isEqualTo("ns"); + assertThat(result.getTableName()).isEqualTo("table"); + verify(admin).getVirtualTableInfo("ns", "table"); + } + + @Test + public void getVirtualTableInfo_WithOperation_OperationDoesNotHaveNamespace_ShouldThrowException() + throws Exception { + // Arrange + manager = new VirtualTableInfoManager(admin, -1); + Operation operation = mock(Operation.class); + when(operation.forNamespace()).thenReturn(Optional.empty()); + when(operation.forTable()).thenReturn(Optional.of("table")); + + // Act Assert + assertThatThrownBy(() -> manager.getVirtualTableInfo(operation)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("does not have"); + } + + @Test + public void getVirtualTableInfo_WithOperation_OperationDoesNotHaveTable_ShouldThrowException() + throws Exception { + // Arrange + manager = new VirtualTableInfoManager(admin, -1); + Operation operation = mock(Operation.class); + when(operation.forNamespace()).thenReturn(Optional.of("ns")); + when(operation.forTable()).thenReturn(Optional.empty()); + + // Act Assert + assertThatThrownBy(() -> manager.getVirtualTableInfo(operation)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("does not have"); + } + + @Test + public void getVirtualTableInfo_VirtualTableExists_ShouldReturnVirtualTableInfo() + throws Exception { + // Arrange + manager = new VirtualTableInfoManager(admin, -1); + VirtualTableInfo virtualTableInfo = createVirtualTableInfo(); + when(admin.getVirtualTableInfo("ns", "table")).thenReturn(Optional.of(virtualTableInfo)); + + // Act + VirtualTableInfo result = manager.getVirtualTableInfo("ns", "table"); + + // Assert + assertThat(result).isNotNull(); + assertThat(result.getNamespaceName()).isEqualTo("ns"); + assertThat(result.getTableName()).isEqualTo("table"); + assertThat(result.getLeftSourceNamespaceName()).isEqualTo("left_ns"); + assertThat(result.getLeftSourceTableName()).isEqualTo("left_table"); + assertThat(result.getRightSourceNamespaceName()).isEqualTo("right_ns"); + assertThat(result.getRightSourceTableName()).isEqualTo("right_table"); + assertThat(result.getJoinType()).isEqualTo(VirtualTableJoinType.INNER); + verify(admin).getVirtualTableInfo("ns", "table"); + } + + @Test + public void getVirtualTableInfo_VirtualTableDoesNotExist_ShouldReturnNull() throws Exception { + // Arrange + manager = new VirtualTableInfoManager(admin, -1); + when(admin.getVirtualTableInfo("ns", "table")).thenReturn(Optional.empty()); + + // Act + VirtualTableInfo result = manager.getVirtualTableInfo("ns", "table"); + + // Assert + assertThat(result).isNull(); + verify(admin).getVirtualTableInfo("ns", "table"); + } + + @Test + public void getVirtualTableInfo_CalledMultipleTimes_ShouldUseCache() throws Exception { + // Arrange + manager = new VirtualTableInfoManager(admin, -1); + VirtualTableInfo virtualTableInfo = createVirtualTableInfo(); + when(admin.getVirtualTableInfo("ns", "table")).thenReturn(Optional.of(virtualTableInfo)); + + // Act + VirtualTableInfo result1 = manager.getVirtualTableInfo("ns", "table"); + VirtualTableInfo result2 = manager.getVirtualTableInfo("ns", "table"); + VirtualTableInfo result3 = manager.getVirtualTableInfo("ns", "table"); + + // Assert + assertThat(result1).isNotNull(); + assertThat(result2).isNotNull(); + assertThat(result3).isNotNull(); + verify(admin, times(1)).getVirtualTableInfo("ns", "table"); + } + + @Test + public void getVirtualTableInfo_WithCacheExpiration_ShouldExpireCache() throws Exception { + // Arrange + manager = new VirtualTableInfoManager(admin, 1); // 1 second expiration + VirtualTableInfo virtualTableInfo = createVirtualTableInfo(); + when(admin.getVirtualTableInfo(anyString(), anyString())) + .thenReturn(Optional.of(virtualTableInfo)); + + // Act + manager.getVirtualTableInfo("ns", "table"); + Thread.sleep(1100); // Wait for cache to expire + manager.getVirtualTableInfo("ns", "table"); + + // Assert + verify(admin, times(2)).getVirtualTableInfo("ns", "table"); + } + + @Test + public void getVirtualTableInfo_VirtualTableDoesNotExist_ShouldNotCacheNullResult() + throws Exception { + // Arrange + manager = new VirtualTableInfoManager(admin, -1); + when(admin.getVirtualTableInfo("ns", "table")).thenReturn(Optional.empty()); + + // Act + manager.getVirtualTableInfo("ns", "table"); + manager.getVirtualTableInfo("ns", "table"); + manager.getVirtualTableInfo("ns", "table"); + + // Assert - admin should be called multiple times because null is not cached + verify(admin, times(3)).getVirtualTableInfo("ns", "table"); + } + + @Test + public void getVirtualTableInfo_AdminThrowsRuntimeException_ShouldThrowRuntimeException() + throws Exception { + // Arrange + manager = new VirtualTableInfoManager(admin, -1); + when(admin.getVirtualTableInfo("ns", "table")) + .thenThrow(new IllegalArgumentException("Table does not exist")); + + // Act Assert + assertThatThrownBy(() -> manager.getVirtualTableInfo("ns", "table")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Table does not exist"); + } + + @Test + public void getVirtualTableInfo_AdminThrowsExecutionException_ShouldWrapInExecutionException() + throws Exception { + // Arrange + manager = new VirtualTableInfoManager(admin, -1); + when(admin.getVirtualTableInfo("ns", "table")) + .thenThrow(new ExecutionException("Storage execution error")); + + // Act Assert + assertThatThrownBy(() -> manager.getVirtualTableInfo("ns", "table")) + .isInstanceOf(ExecutionException.class) + .hasMessageContaining("Getting the virtual table information failed") + .hasMessageContaining("ns.table"); + } + + private VirtualTableInfo createVirtualTableInfo() { + return new VirtualTableInfo() { + @Override + public String getNamespaceName() { + return "ns"; + } + + @Override + public String getTableName() { + return "table"; + } + + @Override + public String getLeftSourceNamespaceName() { + return "left_ns"; + } + + @Override + public String getLeftSourceTableName() { + return "left_table"; + } + + @Override + public String getRightSourceNamespaceName() { + return "right_ns"; + } + + @Override + public String getRightSourceTableName() { + return "right_table"; + } + + @Override + public VirtualTableJoinType getJoinType() { + return VirtualTableJoinType.INNER; + } + }; + } +} diff --git a/core/src/test/java/com/scalar/db/storage/jdbc/JdbcAdminTest.java b/core/src/test/java/com/scalar/db/storage/jdbc/JdbcAdminTest.java index f224b9948b..e57adac730 100644 --- a/core/src/test/java/com/scalar/db/storage/jdbc/JdbcAdminTest.java +++ b/core/src/test/java/com/scalar/db/storage/jdbc/JdbcAdminTest.java @@ -34,9 +34,12 @@ import com.mysql.cj.jdbc.exceptions.CommunicationsException; import com.scalar.db.api.Scan.Ordering.Order; import com.scalar.db.api.TableMetadata; +import com.scalar.db.api.VirtualTableInfo; +import com.scalar.db.api.VirtualTableJoinType; import com.scalar.db.common.CoreError; import com.scalar.db.exception.storage.ExecutionException; import com.scalar.db.io.DataType; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import java.sql.Connection; import java.sql.DatabaseMetaData; import java.sql.JDBCType; @@ -49,6 +52,7 @@ import java.util.Arrays; import java.util.Collections; import java.util.HashMap; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -98,6 +102,8 @@ public class JdbcAdminTest { @Mock private BasicDataSource dataSource; @Mock private Connection connection; @Mock private JdbcConfig config; + @Mock private TableMetadataService tableMetadataService; + @Mock private VirtualTableMetadataService virtualTableMetadataService; @BeforeEach public void setUp() throws Exception { @@ -112,7 +118,7 @@ private JdbcAdmin createJdbcAdminFor(RdbEngine rdbEngine) { RdbEngineStrategy st = RdbEngine.createRdbEngineStrategy(rdbEngine); try (MockedStatic mocked = mockStatic(RdbEngineFactory.class)) { mocked.when(() -> RdbEngineFactory.create(any(JdbcConfig.class))).thenReturn(st); - return new JdbcAdmin(dataSource, config); + return new JdbcAdmin(dataSource, config, virtualTableMetadataService); } } @@ -122,7 +128,16 @@ private JdbcAdmin createJdbcAdminFor(RdbEngineStrategy rdbEngineStrategy) { mocked .when(() -> RdbEngineFactory.create(any(JdbcConfig.class))) .thenReturn(rdbEngineStrategy); - return new JdbcAdmin(dataSource, config); + return new JdbcAdmin(dataSource, config, virtualTableMetadataService); + } + } + + private JdbcAdmin createJdbcAdmin() { + // Arrange + RdbEngineStrategy st = RdbEngine.createRdbEngineStrategy(RdbEngine.MYSQL); + try (MockedStatic mocked = mockStatic(RdbEngineFactory.class)) { + mocked.when(() -> RdbEngineFactory.create(any(JdbcConfig.class))).thenReturn(st); + return new JdbcAdmin(dataSource, config, tableMetadataService, virtualTableMetadataService); } } @@ -154,6 +169,28 @@ private void mockUndefinedTableError(RdbEngine rdbEngine, SQLException sqlExcept } } + private ResultSet mockResultSet(SelectAllFromMetadataTableResultSetMocker.Row... rows) + throws SQLException { + ResultSet resultSet = mock(ResultSet.class); + // Everytime the ResultSet.next() method will be called, the ResultSet.getXXX methods call be + // mocked to return the current row data + doAnswer(new SelectAllFromMetadataTableResultSetMocker(Arrays.asList(rows))) + .when(resultSet) + .next(); + return resultSet; + } + + private ResultSet mockResultSet(SelectFullTableNameFromMetadataTableResultSetMocker.Row... rows) + throws SQLException { + ResultSet resultSet = mock(ResultSet.class); + // Everytime the ResultSet.next() method will be called, the ResultSet.getXXX methods call be + // mocked to return the current row data + doAnswer(new SelectFullTableNameFromMetadataTableResultSetMocker(Arrays.asList(rows))) + .when(resultSet) + .next(); + return resultSet; + } + @Test public void getTableMetadata_forMysql_ShouldReturnTableMetadata() throws SQLException, ExecutionException { @@ -283,28 +320,6 @@ private void getTableMetadata_forX_ShouldReturnTableMetadata( verify(connection).prepareStatement(expectedSelectStatements); } - public ResultSet mockResultSet(SelectAllFromMetadataTableResultSetMocker.Row... rows) - throws SQLException { - ResultSet resultSet = mock(ResultSet.class); - // Everytime the ResultSet.next() method will be called, the ResultSet.getXXX methods call be - // mocked to return the current row data - doAnswer(new SelectAllFromMetadataTableResultSetMocker(Arrays.asList(rows))) - .when(resultSet) - .next(); - return resultSet; - } - - public ResultSet mockResultSet(SelectFullTableNameFromMetadataTableResultSetMocker.Row... rows) - throws SQLException { - ResultSet resultSet = mock(ResultSet.class); - // Everytime the ResultSet.next() method will be called, the ResultSet.getXXX methods call be - // mocked to return the current row data - doAnswer(new SelectFullTableNameFromMetadataTableResultSetMocker(Arrays.asList(rows))) - .when(resultSet) - .next(); - return resultSet; - } - @Test public void getTableMetadata_MetadataSchemaNotExistsForX_ShouldReturnNull() throws SQLException, ExecutionException { @@ -334,6 +349,108 @@ private void getTableMetadata_MetadataTableNotExistsForX_ShouldReturnNull(RdbEng assertThat(actual).isNull(); } + @Test + public void getTableMetadata_VirtualTableExists_ShouldReturnMergedTableMetadata() + throws Exception { + // Arrange + String namespace = "ns"; + String table = "vtable"; + String leftSourceNamespace = "ns"; + String leftSourceTable = "left_table"; + String rightSourceNamespace = "ns"; + String rightSourceTable = "right_table"; + + when(dataSource.getConnection()).thenReturn(connection); + + // Mock tableMetadataService to return null (not a regular table) + when(tableMetadataService.getTableMetadata(any(Connection.class), eq(namespace), eq(table))) + .thenReturn(null); + + // Mock virtualTableMetadataService to return virtual table info + VirtualTableInfo virtualTableInfo = mock(VirtualTableInfo.class); + when(virtualTableInfo.getNamespaceName()).thenReturn(namespace); + when(virtualTableInfo.getTableName()).thenReturn(table); + when(virtualTableInfo.getLeftSourceNamespaceName()).thenReturn(leftSourceNamespace); + when(virtualTableInfo.getLeftSourceTableName()).thenReturn(leftSourceTable); + when(virtualTableInfo.getRightSourceNamespaceName()).thenReturn(rightSourceNamespace); + when(virtualTableInfo.getRightSourceTableName()).thenReturn(rightSourceTable); + when(virtualTableInfo.getJoinType()).thenReturn(VirtualTableJoinType.INNER); + + when(virtualTableMetadataService.getVirtualTableInfo( + any(Connection.class), eq(namespace), eq(table))) + .thenReturn(virtualTableInfo); + + // Mock source table metadata + TableMetadata leftSourceTableMetadata = + TableMetadata.newBuilder() + .addPartitionKey("pk1") + .addPartitionKey("pk2") + .addClusteringKey("ck1", Order.ASC) + .addClusteringKey("ck2", Order.DESC) + .addColumn("pk1", DataType.INT) + .addColumn("pk2", DataType.TEXT) + .addColumn("ck1", DataType.BIGINT) + .addColumn("ck2", DataType.TEXT) + .addColumn("col1", DataType.BOOLEAN) + .addColumn("col2", DataType.DOUBLE) + .addColumn("col3", DataType.BLOB) + .addSecondaryIndex("ck1") + .addSecondaryIndex("col1") + .addSecondaryIndex("col2") + .build(); + + TableMetadata rightSourceTableMetadata = + TableMetadata.newBuilder() + .addPartitionKey("pk1") + .addPartitionKey("pk2") + .addClusteringKey("ck1", Order.ASC) + .addClusteringKey("ck2", Order.DESC) + .addColumn("pk1", DataType.INT) + .addColumn("pk2", DataType.TEXT) + .addColumn("ck1", DataType.BIGINT) + .addColumn("ck2", DataType.TEXT) + .addColumn("col4", DataType.FLOAT) + .addColumn("col5", DataType.DATE) + .addColumn("col6", DataType.TIMESTAMP) + .addSecondaryIndex("ck2") + .addSecondaryIndex("col4") + .build(); + + when(tableMetadataService.getTableMetadata( + any(Connection.class), eq(leftSourceNamespace), eq(leftSourceTable))) + .thenReturn(leftSourceTableMetadata); + when(tableMetadataService.getTableMetadata( + any(Connection.class), eq(rightSourceNamespace), eq(rightSourceTable))) + .thenReturn(rightSourceTableMetadata); + + JdbcAdmin admin = createJdbcAdmin(); + + // Act + TableMetadata result = admin.getTableMetadata(namespace, table); + + // Assert + assertThat(result).isNotNull(); + assertThat(result.getPartitionKeyNames()).containsExactly("pk1", "pk2"); + assertThat(result.getClusteringKeyNames()).containsExactly("ck1", "ck2"); + assertThat(result.getClusteringOrder("ck1")).isEqualTo(Order.ASC); + assertThat(result.getClusteringOrder("ck2")).isEqualTo(Order.DESC); + assertThat(result.getColumnNames()) + .containsExactlyInAnyOrder( + "pk1", "pk2", "ck1", "ck2", "col1", "col2", "col3", "col4", "col5", "col6"); + assertThat(result.getColumnDataType("pk1")).isEqualTo(DataType.INT); + assertThat(result.getColumnDataType("pk2")).isEqualTo(DataType.TEXT); + assertThat(result.getColumnDataType("ck1")).isEqualTo(DataType.BIGINT); + assertThat(result.getColumnDataType("ck2")).isEqualTo(DataType.TEXT); + assertThat(result.getColumnDataType("col1")).isEqualTo(DataType.BOOLEAN); + assertThat(result.getColumnDataType("col2")).isEqualTo(DataType.DOUBLE); + assertThat(result.getColumnDataType("col3")).isEqualTo(DataType.BLOB); + assertThat(result.getColumnDataType("col4")).isEqualTo(DataType.FLOAT); + assertThat(result.getColumnDataType("col5")).isEqualTo(DataType.DATE); + assertThat(result.getColumnDataType("col6")).isEqualTo(DataType.TIMESTAMP); + assertThat(result.getSecondaryIndexNames()) + .containsExactlyInAnyOrder("ck1", "ck2", "col1", "col2", "col4"); + } + @Test public void createNamespace_forMysql_shouldExecuteCreateNamespaceStatement() throws ExecutionException, SQLException { @@ -1705,6 +1822,80 @@ private void truncateTable_forX_shouldExecuteTruncateTableStatement( verify(truncateTableStatement).execute(expectedTruncateTableStatement); } + @Test + public void truncateTable_VirtualTableExists_ShouldTruncateSourceTables() throws Exception { + // Arrange + String namespace = "ns"; + String table = "vtable"; + String leftSourceNamespace = "ns"; + String leftSourceTable = "left_table"; + String rightSourceNamespace = "ns"; + String rightSourceTable = "right_table"; + + when(dataSource.getConnection()).thenReturn(connection); + + VirtualTableInfo virtualTableInfo = mock(VirtualTableInfo.class); + when(virtualTableInfo.getLeftSourceNamespaceName()).thenReturn(leftSourceNamespace); + when(virtualTableInfo.getLeftSourceTableName()).thenReturn(leftSourceTable); + when(virtualTableInfo.getRightSourceNamespaceName()).thenReturn(rightSourceNamespace); + when(virtualTableInfo.getRightSourceTableName()).thenReturn(rightSourceTable); + + when(virtualTableMetadataService.getVirtualTableInfo(connection, namespace, table)) + .thenReturn(virtualTableInfo); + + Statement statement = mock(Statement.class); + when(connection.createStatement()).thenReturn(statement); + + ArgumentCaptor sqlCaptor = ArgumentCaptor.forClass(String.class); + + JdbcAdmin admin = createJdbcAdmin(); + + // Act + admin.truncateTable(namespace, table); + + // Assert + verify(statement, times(2)).execute(sqlCaptor.capture()); + List executedSqls = sqlCaptor.getAllValues(); + assertThat(executedSqls).hasSize(2); + assertThat(executedSqls.get(0)).isEqualTo("TRUNCATE TABLE `ns`.`left_table`"); + assertThat(executedSqls.get(1)).isEqualTo("TRUNCATE TABLE `ns`.`right_table`"); + } + + @Test + public void truncateTable_SQLExceptionThrown_ShouldThrowExecutionException() throws Exception { + // Arrange + String namespace = "ns"; + String table = "vtable"; + String leftSourceNamespace = "ns"; + String leftSourceTable = "left_table"; + String rightSourceNamespace = "ns"; + String rightSourceTable = "right_table"; + + when(dataSource.getConnection()).thenReturn(connection); + + VirtualTableInfo virtualTableInfo = mock(VirtualTableInfo.class); + when(virtualTableInfo.getLeftSourceNamespaceName()).thenReturn(leftSourceNamespace); + when(virtualTableInfo.getLeftSourceTableName()).thenReturn(leftSourceTable); + when(virtualTableInfo.getRightSourceNamespaceName()).thenReturn(rightSourceNamespace); + when(virtualTableInfo.getRightSourceTableName()).thenReturn(rightSourceTable); + + when(virtualTableMetadataService.getVirtualTableInfo(connection, namespace, table)) + .thenReturn(virtualTableInfo); + + Statement statement = mock(Statement.class); + when(connection.createStatement()).thenReturn(statement); + + SQLException sqlException = new SQLException("SQL error occurred"); + when(statement.execute(anyString())).thenThrow(sqlException); + + JdbcAdmin admin = createJdbcAdmin(); + + // Act Assert + assertThatThrownBy(() -> admin.truncateTable(namespace, table)) + .isInstanceOf(ExecutionException.class) + .hasCause(sqlException); + } + @Test public void dropTable_forMysqlWithNoMoreMetadataAfterDeletion_shouldDropTableAndDeleteMetadata() throws Exception { @@ -1955,6 +2146,108 @@ private void dropTable_forXWithNoMoreMetadataAfterDeletion_shouldDropTableAndDel } } + @Test + public void dropTable_VirtualTableExists_ShouldDropViewAndDeleteMetadata() throws Exception { + // Arrange + String namespace = "ns"; + String table = "vtable"; + + when(dataSource.getConnection()).thenReturn(connection); + + VirtualTableInfo virtualTableInfo = mock(VirtualTableInfo.class); + when(virtualTableMetadataService.getVirtualTableInfo(connection, namespace, table)) + .thenReturn(virtualTableInfo); + + Statement statement = mock(Statement.class); + when(connection.createStatement()).thenReturn(statement); + + doNothing() + .when(virtualTableMetadataService) + .deleteFromVirtualTablesTable(any(Connection.class), eq(namespace), eq(table)); + doNothing() + .when(virtualTableMetadataService) + .deleteVirtualTablesTableIfEmpty(any(Connection.class)); + + ArgumentCaptor sqlCaptor = ArgumentCaptor.forClass(String.class); + + JdbcAdmin admin = createJdbcAdmin(); + + // Act + admin.dropTable(namespace, table); + + // Assert + verify(statement).execute(sqlCaptor.capture()); + String executedSql = sqlCaptor.getValue(); + assertThat(executedSql).isEqualTo("DROP VIEW `ns`.`vtable`"); + verify(virtualTableMetadataService) + .deleteFromVirtualTablesTable(eq(connection), eq(namespace), eq(table)); + verify(virtualTableMetadataService).deleteVirtualTablesTableIfEmpty(eq(connection)); + } + + @Test + public void dropTable_SourceTableUsedByVirtualTable_ShouldThrowIllegalArgumentException() + throws Exception { + // Arrange + String namespace = "ns"; + String table = "source_table"; + + when(dataSource.getConnection()).thenReturn(connection); + + // Not a virtual table itself + when(virtualTableMetadataService.getVirtualTableInfo(connection, namespace, table)) + .thenReturn(null); + + // But used as a source table + VirtualTableInfo virtualTableInfo1 = mock(VirtualTableInfo.class); + when(virtualTableInfo1.getNamespaceName()).thenReturn("ns"); + when(virtualTableInfo1.getTableName()).thenReturn("vtable1"); + + VirtualTableInfo virtualTableInfo2 = mock(VirtualTableInfo.class); + when(virtualTableInfo2.getNamespaceName()).thenReturn("ns"); + when(virtualTableInfo2.getTableName()).thenReturn("vtable2"); + + List virtualTableInfos = Arrays.asList(virtualTableInfo1, virtualTableInfo2); + + when(virtualTableMetadataService.getVirtualTableInfosBySourceTable( + connection, namespace, table)) + .thenReturn(virtualTableInfos); + + JdbcAdmin admin = createJdbcAdmin(); + + // Act Assert + assertThatThrownBy(() -> admin.dropTable(namespace, table)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("ns.source_table") + .hasMessageContaining("ns.vtable1") + .hasMessageContaining("ns.vtable2"); + } + + @Test + public void dropTable_SQLExceptionThrown_ShouldThrowExecutionException() throws Exception { + // Arrange + String namespace = "ns"; + String table = "vtable"; + + when(dataSource.getConnection()).thenReturn(connection); + + VirtualTableInfo virtualTableInfo = mock(VirtualTableInfo.class); + when(virtualTableMetadataService.getVirtualTableInfo(connection, namespace, table)) + .thenReturn(virtualTableInfo); + + Statement statement = mock(Statement.class); + when(connection.createStatement()).thenReturn(statement); + + SQLException sqlException = new SQLException("SQL error occurred"); + when(statement.execute(anyString())).thenThrow(sqlException); + + JdbcAdmin admin = createJdbcAdmin(); + + // Act Assert + assertThatThrownBy(() -> admin.dropTable(namespace, table)) + .isInstanceOf(ExecutionException.class) + .hasCause(sqlException); + } + @Test public void dropNamespace_forMysql_shouldDropNamespace() throws Exception { dropSchema_forX_shouldDropSchema(RdbEngine.MYSQL, "DROP SCHEMA `my_ns`"); @@ -2078,8 +2371,8 @@ private void dropNamespace_WithNonScalarDBTableLeftForX_ShouldThrowIllegalArgume } @Test - public void getNamespaceTables_forMysql_ShouldReturnTableNames() throws Exception { - getNamespaceTables_forX_ShouldReturnTableNames( + public void getNamespaceTableNames_forMysql_ShouldReturnTableNames() throws Exception { + getNamespaceTableNames_forX_ShouldReturnTableNames( RdbEngine.MYSQL, "SELECT DISTINCT `full_table_name` FROM `" + METADATA_SCHEMA @@ -2087,8 +2380,8 @@ public void getNamespaceTables_forMysql_ShouldReturnTableNames() throws Exceptio } @Test - public void getNamespaceTables_forPostgresql_ShouldReturnTableNames() throws Exception { - getNamespaceTables_forX_ShouldReturnTableNames( + public void getNamespaceTableNames_forPostgresql_ShouldReturnTableNames() throws Exception { + getNamespaceTableNames_forX_ShouldReturnTableNames( RdbEngine.POSTGRESQL, "SELECT DISTINCT \"full_table_name\" FROM \"" + METADATA_SCHEMA @@ -2096,8 +2389,8 @@ public void getNamespaceTables_forPostgresql_ShouldReturnTableNames() throws Exc } @Test - public void getNamespaceTables_forSqlServer_ShouldReturnTableNames() throws Exception { - getNamespaceTables_forX_ShouldReturnTableNames( + public void getNamespaceTableNames_forSqlServer_ShouldReturnTableNames() throws Exception { + getNamespaceTableNames_forX_ShouldReturnTableNames( RdbEngine.SQL_SERVER, "SELECT DISTINCT [full_table_name] FROM [" + METADATA_SCHEMA @@ -2105,8 +2398,8 @@ public void getNamespaceTables_forSqlServer_ShouldReturnTableNames() throws Exce } @Test - public void getNamespaceTables_forOracle_ShouldReturnTableNames() throws Exception { - getNamespaceTables_forX_ShouldReturnTableNames( + public void getNamespaceTableNames_forOracle_ShouldReturnTableNames() throws Exception { + getNamespaceTableNames_forX_ShouldReturnTableNames( RdbEngine.ORACLE, "SELECT DISTINCT \"full_table_name\" FROM \"" + METADATA_SCHEMA @@ -2114,8 +2407,8 @@ public void getNamespaceTables_forOracle_ShouldReturnTableNames() throws Excepti } @Test - public void getNamespaceTables_forSqlite_ShouldReturnTableNames() throws Exception { - getNamespaceTables_forX_ShouldReturnTableNames( + public void getNamespaceTableNames_forSqlite_ShouldReturnTableNames() throws Exception { + getNamespaceTableNames_forX_ShouldReturnTableNames( RdbEngine.SQLITE, "SELECT DISTINCT \"full_table_name\" FROM \"" + METADATA_SCHEMA @@ -2123,15 +2416,15 @@ public void getNamespaceTables_forSqlite_ShouldReturnTableNames() throws Excepti } @Test - public void getNamespaceTables_forDb2_ShouldReturnTableNames() throws Exception { - getNamespaceTables_forX_ShouldReturnTableNames( + public void getNamespaceTableNames_forDb2_ShouldReturnTableNames() throws Exception { + getNamespaceTableNames_forX_ShouldReturnTableNames( RdbEngine.DB2, "SELECT DISTINCT \"full_table_name\" FROM \"" + METADATA_SCHEMA + "\".\"metadata\" WHERE \"full_table_name\" LIKE ?"); } - private void getNamespaceTables_forX_ShouldReturnTableNames( + private void getNamespaceTableNames_forX_ShouldReturnTableNames( RdbEngine rdbEngine, String expectedSelectStatement) throws Exception { // Arrange String namespace = "ns1"; @@ -2170,6 +2463,134 @@ private void getNamespaceTables_forX_ShouldReturnTableNames( verify(preparedStatement).setString(1, namespace + ".%"); } + @Test + public void getNamespaceTableNames_RegularAndVirtualTablesExist_ShouldReturnBoth() + throws Exception { + // Arrange + String namespace = "ns"; + + when(dataSource.getConnection()).thenReturn(connection); + + Set regularTables = new HashSet<>(Arrays.asList("table1", "table2")); + Set virtualTables = new HashSet<>(Arrays.asList("vtable1", "vtable2")); + + when(tableMetadataService.getNamespaceTableNames(connection, namespace)) + .thenReturn(regularTables); + when(virtualTableMetadataService.getNamespaceTableNames(connection, namespace)) + .thenReturn(virtualTables); + + JdbcAdmin admin = createJdbcAdmin(); + + // Act + Set result = admin.getNamespaceTableNames(namespace); + + // Assert + assertThat(result).containsExactlyInAnyOrder("table1", "table2", "vtable1", "vtable2"); + verify(tableMetadataService).getNamespaceTableNames(eq(connection), eq(namespace)); + verify(virtualTableMetadataService).getNamespaceTableNames(eq(connection), eq(namespace)); + } + + @Test + public void getNamespaceTableNames_OnlyRegularTablesExist_ShouldReturnRegularTablesOnly() + throws Exception { + // Arrange + String namespace = "ns"; + + when(dataSource.getConnection()).thenReturn(connection); + + Set regularTables = new HashSet<>(Arrays.asList("table1", "table2")); + Set virtualTables = Collections.emptySet(); + + when(tableMetadataService.getNamespaceTableNames(connection, namespace)) + .thenReturn(regularTables); + when(virtualTableMetadataService.getNamespaceTableNames(connection, namespace)) + .thenReturn(virtualTables); + + JdbcAdmin admin = createJdbcAdmin(); + + // Act + Set result = admin.getNamespaceTableNames(namespace); + + // Assert + assertThat(result).containsExactlyInAnyOrder("table1", "table2"); + verify(tableMetadataService).getNamespaceTableNames(eq(connection), eq(namespace)); + verify(virtualTableMetadataService).getNamespaceTableNames(eq(connection), eq(namespace)); + } + + @Test + public void getNamespaceTableNames_OnlyVirtualTablesExist_ShouldReturnVirtualTablesOnly() + throws Exception { + // Arrange + String namespace = "ns"; + + when(dataSource.getConnection()).thenReturn(connection); + + Set regularTables = Collections.emptySet(); + Set virtualTables = new HashSet<>(Arrays.asList("vtable1", "vtable2")); + + when(tableMetadataService.getNamespaceTableNames(connection, namespace)) + .thenReturn(regularTables); + when(virtualTableMetadataService.getNamespaceTableNames(connection, namespace)) + .thenReturn(virtualTables); + + JdbcAdmin admin = createJdbcAdmin(); + + // Act + Set result = admin.getNamespaceTableNames(namespace); + + // Assert + assertThat(result).containsExactlyInAnyOrder("vtable1", "vtable2"); + verify(tableMetadataService).getNamespaceTableNames(eq(connection), eq(namespace)); + verify(virtualTableMetadataService).getNamespaceTableNames(eq(connection), eq(namespace)); + } + + @Test + public void getNamespaceTableNames_NoTablesExist_ShouldReturnEmptySet() throws Exception { + // Arrange + String namespace = "ns"; + + when(dataSource.getConnection()).thenReturn(connection); + + Set regularTables = Collections.emptySet(); + Set virtualTables = Collections.emptySet(); + + when(tableMetadataService.getNamespaceTableNames(connection, namespace)) + .thenReturn(regularTables); + when(virtualTableMetadataService.getNamespaceTableNames(connection, namespace)) + .thenReturn(virtualTables); + + JdbcAdmin admin = createJdbcAdmin(); + + // Act + Set result = admin.getNamespaceTableNames(namespace); + + // Assert + assertThat(result).isEmpty(); + verify(tableMetadataService).getNamespaceTableNames(eq(connection), eq(namespace)); + verify(virtualTableMetadataService).getNamespaceTableNames(eq(connection), eq(namespace)); + } + + @Test + public void getNamespaceTableNames_SQLExceptionThrown_ShouldThrowExecutionException() + throws Exception { + // Arrange + String namespace = "ns"; + + when(dataSource.getConnection()).thenReturn(connection); + + SQLException sqlException = new SQLException("SQL error occurred"); + + when(tableMetadataService.getNamespaceTableNames(connection, namespace)) + .thenThrow(sqlException); + + JdbcAdmin admin = createJdbcAdmin(); + + // Act Assert + assertThatThrownBy(() -> admin.getNamespaceTableNames(namespace)) + .isInstanceOf(ExecutionException.class) + .hasCause(sqlException); + } + @Test public void namespaceExists_forMysqlWithExistingNamespace_shouldReturnTrue() throws Exception { namespaceExists_forXWithExistingNamespace_ShouldReturnTrue( @@ -2517,34 +2938,273 @@ private void createIndex_ForColumnTypeWithoutRequiredAlterationForX_ShouldCreate .isEqualTo(expectedUpdateTableMetadataStatement); } - @Test - public void dropIndex_forColumnTypeWithoutRequiredAlterationForMysql_ShouldDropIndexProperly() - throws Exception { - dropIndex_forColumnTypeWithoutRequiredAlterationForX_ShouldDropIndexProperly( - RdbEngine.MYSQL, - "SELECT `column_name`,`data_type`,`key_type`,`clustering_order`,`indexed` FROM `" - + METADATA_SCHEMA - + "`.`metadata` WHERE `full_table_name`=? ORDER BY `ordinal_position` ASC", - "DROP INDEX `index_my_ns_my_tbl_my_column` ON `my_ns`.`my_tbl`", - "UPDATE `" - + METADATA_SCHEMA - + "`.`metadata` SET `indexed`=false WHERE `full_table_name`='my_ns.my_tbl' AND `column_name`='my_column'"); - } - @Test public void - dropIndex_forColumnTypeWithoutRequiredAlterationForPostgresql_ShouldDropIndexProperly() + createIndex_VirtualTableWithColumnInLeftSourceTable_ShouldCreateIndexOnLeftSourceTable() throws Exception { - dropIndex_forColumnTypeWithoutRequiredAlterationForX_ShouldDropIndexProperly( - RdbEngine.POSTGRESQL, - "SELECT \"column_name\",\"data_type\",\"key_type\",\"clustering_order\",\"indexed\" FROM \"" - + METADATA_SCHEMA - + "\".\"metadata\" WHERE \"full_table_name\"=? ORDER BY \"ordinal_position\" ASC", - "DROP INDEX \"my_ns\".\"index_my_ns_my_tbl_my_column\"", - "UPDATE \"" - + METADATA_SCHEMA - + "\".\"metadata\" SET \"indexed\"=false WHERE \"full_table_name\"='my_ns.my_tbl' AND \"column_name\"='my_column'"); - } + // Arrange + String namespace = "ns"; + String table = "vtable"; + String leftSourceNamespace = "ns"; + String leftSourceTable = "left_table"; + String rightSourceNamespace = "ns"; + String rightSourceTable = "right_table"; + String columnName = "col1"; + + when(dataSource.getConnection()).thenReturn(connection); + + VirtualTableInfo virtualTableInfo = mock(VirtualTableInfo.class); + when(virtualTableInfo.getLeftSourceNamespaceName()).thenReturn(leftSourceNamespace); + when(virtualTableInfo.getLeftSourceTableName()).thenReturn(leftSourceTable); + when(virtualTableInfo.getRightSourceNamespaceName()).thenReturn(rightSourceNamespace); + when(virtualTableInfo.getRightSourceTableName()).thenReturn(rightSourceTable); + + when(virtualTableMetadataService.getVirtualTableInfo(connection, namespace, table)) + .thenReturn(virtualTableInfo); + + TableMetadata leftSourceTableMetadata = + TableMetadata.newBuilder() + .addPartitionKey("pk1") + .addColumn("pk1", DataType.TEXT) + .addColumn("col1", DataType.INT) + .build(); + + TableMetadata rightSourceTableMetadata = + TableMetadata.newBuilder() + .addPartitionKey("pk1") + .addColumn("pk1", DataType.TEXT) + .addColumn("col2", DataType.INT) + .build(); + + when(tableMetadataService.getTableMetadata(connection, leftSourceNamespace, leftSourceTable)) + .thenReturn(leftSourceTableMetadata); + when(tableMetadataService.getTableMetadata(connection, rightSourceNamespace, rightSourceTable)) + .thenReturn(rightSourceTableMetadata); + + Statement statement = mock(Statement.class); + when(connection.createStatement()).thenReturn(statement); + + JdbcAdmin admin = createJdbcAdmin(); + + // Act + admin.createIndex(namespace, table, columnName, Collections.emptyMap()); + + // Assert + verify(virtualTableMetadataService) + .getVirtualTableInfo(eq(connection), eq(namespace), eq(table)); + verify(tableMetadataService) + .getTableMetadata(eq(connection), eq(leftSourceNamespace), eq(leftSourceTable)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(String.class); + verify(statement).execute(captor.capture()); + assertThat(captor.getValue()) + .isEqualTo("CREATE INDEX `index_ns_left_table_col1` ON `ns`.`left_table` (`col1`)"); + verify(tableMetadataService) + .updateTableMetadata( + eq(connection), eq(leftSourceNamespace), eq(leftSourceTable), eq(columnName), eq(true)); + } + + @Test + public void + createIndex_VirtualTableWithColumnInRightSourceTable_ShouldCreateIndexOnRightSourceTable() + throws Exception { + // Arrange + String namespace = "ns"; + String table = "vtable"; + String leftSourceNamespace = "ns"; + String leftSourceTable = "left_table"; + String rightSourceNamespace = "ns"; + String rightSourceTable = "right_table"; + String columnName = "col2"; + + when(dataSource.getConnection()).thenReturn(connection); + + VirtualTableInfo virtualTableInfo = mock(VirtualTableInfo.class); + when(virtualTableInfo.getLeftSourceNamespaceName()).thenReturn(leftSourceNamespace); + when(virtualTableInfo.getLeftSourceTableName()).thenReturn(leftSourceTable); + when(virtualTableInfo.getRightSourceNamespaceName()).thenReturn(rightSourceNamespace); + when(virtualTableInfo.getRightSourceTableName()).thenReturn(rightSourceTable); + + when(virtualTableMetadataService.getVirtualTableInfo(connection, namespace, table)) + .thenReturn(virtualTableInfo); + + TableMetadata leftSourceTableMetadata = + TableMetadata.newBuilder() + .addPartitionKey("pk1") + .addColumn("pk1", DataType.TEXT) + .addColumn("col1", DataType.INT) + .build(); + + TableMetadata rightSourceTableMetadata = + TableMetadata.newBuilder() + .addPartitionKey("pk1") + .addColumn("pk1", DataType.TEXT) + .addColumn("col2", DataType.INT) + .build(); + + when(tableMetadataService.getTableMetadata(connection, leftSourceNamespace, leftSourceTable)) + .thenReturn(leftSourceTableMetadata); + when(tableMetadataService.getTableMetadata(connection, rightSourceNamespace, rightSourceTable)) + .thenReturn(rightSourceTableMetadata); + + Statement statement = mock(Statement.class); + when(connection.createStatement()).thenReturn(statement); + + JdbcAdmin admin = createJdbcAdmin(); + + // Act + admin.createIndex(namespace, table, columnName, Collections.emptyMap()); + + // Assert + verify(virtualTableMetadataService) + .getVirtualTableInfo(eq(connection), eq(namespace), eq(table)); + verify(tableMetadataService) + .getTableMetadata(eq(connection), eq(leftSourceNamespace), eq(leftSourceTable)); + verify(tableMetadataService) + .getTableMetadata(eq(connection), eq(rightSourceNamespace), eq(rightSourceTable)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(String.class); + verify(statement).execute(captor.capture()); + assertThat(captor.getValue()) + .isEqualTo("CREATE INDEX `index_ns_right_table_col2` ON `ns`.`right_table` (`col2`)"); + verify(tableMetadataService) + .updateTableMetadata( + eq(connection), + eq(rightSourceNamespace), + eq(rightSourceTable), + eq(columnName), + eq(true)); + } + + @Test + public void createIndex_VirtualTableWithPrimaryKeyColumn_ShouldCreateIndexOnBothSourceTables() + throws Exception { + // Arrange + String namespace = "ns"; + String table = "vtable"; + String leftSourceNamespace = "ns"; + String leftSourceTable = "left_table"; + String rightSourceNamespace = "ns"; + String rightSourceTable = "right_table"; + String columnName = "pk1"; + + when(dataSource.getConnection()).thenReturn(connection); + + VirtualTableInfo virtualTableInfo = mock(VirtualTableInfo.class); + when(virtualTableInfo.getLeftSourceNamespaceName()).thenReturn(leftSourceNamespace); + when(virtualTableInfo.getLeftSourceTableName()).thenReturn(leftSourceTable); + when(virtualTableInfo.getRightSourceNamespaceName()).thenReturn(rightSourceNamespace); + when(virtualTableInfo.getRightSourceTableName()).thenReturn(rightSourceTable); + + when(virtualTableMetadataService.getVirtualTableInfo(connection, namespace, table)) + .thenReturn(virtualTableInfo); + + TableMetadata leftSourceTableMetadata = + TableMetadata.newBuilder() + .addPartitionKey("pk1") + .addColumn("pk1", DataType.INT) + .addColumn("col1", DataType.INT) + .build(); + + TableMetadata rightSourceTableMetadata = + TableMetadata.newBuilder() + .addPartitionKey("pk1") + .addColumn("pk1", DataType.INT) + .addColumn("col2", DataType.INT) + .build(); + + when(tableMetadataService.getTableMetadata(connection, leftSourceNamespace, leftSourceTable)) + .thenReturn(leftSourceTableMetadata); + when(tableMetadataService.getTableMetadata(connection, rightSourceNamespace, rightSourceTable)) + .thenReturn(rightSourceTableMetadata); + + Statement statement = mock(Statement.class); + when(connection.createStatement()).thenReturn(statement); + + JdbcAdmin admin = createJdbcAdmin(); + + // Act + admin.createIndex(namespace, table, columnName, Collections.emptyMap()); + + // Assert + verify(virtualTableMetadataService) + .getVirtualTableInfo(eq(connection), eq(namespace), eq(table)); + verify(tableMetadataService) + .getTableMetadata(eq(connection), eq(leftSourceNamespace), eq(leftSourceTable)); + verify(tableMetadataService) + .getTableMetadata(eq(connection), eq(rightSourceNamespace), eq(rightSourceTable)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(String.class); + verify(statement, times(2)).execute(captor.capture()); + assertThat(captor.getAllValues()) + .containsExactly( + "CREATE INDEX `index_ns_left_table_pk1` ON `ns`.`left_table` (`pk1`)", + "CREATE INDEX `index_ns_right_table_pk1` ON `ns`.`right_table` (`pk1`)"); + verify(tableMetadataService) + .updateTableMetadata( + eq(connection), eq(leftSourceNamespace), eq(leftSourceTable), eq(columnName), eq(true)); + verify(tableMetadataService) + .updateTableMetadata( + eq(connection), + eq(rightSourceNamespace), + eq(rightSourceTable), + eq(columnName), + eq(true)); + } + + @Test + public void createIndex_SQLExceptionThrown_ShouldThrowExecutionException() throws Exception { + // Arrange + String namespace = "ns"; + String table = "vtable"; + String columnName = "col1"; + + when(dataSource.getConnection()).thenReturn(connection); + + SQLException sqlException = new SQLException("SQL error occurred"); + when(virtualTableMetadataService.getVirtualTableInfo(connection, namespace, table)) + .thenThrow(sqlException); + + JdbcAdmin admin = createJdbcAdmin(); + + // Act Assert + assertThatThrownBy( + () -> admin.createIndex(namespace, table, columnName, Collections.emptyMap())) + .isInstanceOf(ExecutionException.class) + .hasMessageContaining("Creating the secondary index") + .hasMessageContaining(columnName) + .hasMessageContaining(getFullTableName(namespace, table)) + .hasCause(sqlException); + } + + @Test + public void dropIndex_forColumnTypeWithoutRequiredAlterationForMysql_ShouldDropIndexProperly() + throws Exception { + dropIndex_forColumnTypeWithoutRequiredAlterationForX_ShouldDropIndexProperly( + RdbEngine.MYSQL, + "SELECT `column_name`,`data_type`,`key_type`,`clustering_order`,`indexed` FROM `" + + METADATA_SCHEMA + + "`.`metadata` WHERE `full_table_name`=? ORDER BY `ordinal_position` ASC", + "DROP INDEX `index_my_ns_my_tbl_my_column` ON `my_ns`.`my_tbl`", + "UPDATE `" + + METADATA_SCHEMA + + "`.`metadata` SET `indexed`=false WHERE `full_table_name`='my_ns.my_tbl' AND `column_name`='my_column'"); + } + + @Test + public void + dropIndex_forColumnTypeWithoutRequiredAlterationForPostgresql_ShouldDropIndexProperly() + throws Exception { + dropIndex_forColumnTypeWithoutRequiredAlterationForX_ShouldDropIndexProperly( + RdbEngine.POSTGRESQL, + "SELECT \"column_name\",\"data_type\",\"key_type\",\"clustering_order\",\"indexed\" FROM \"" + + METADATA_SCHEMA + + "\".\"metadata\" WHERE \"full_table_name\"=? ORDER BY \"ordinal_position\" ASC", + "DROP INDEX \"my_ns\".\"index_my_ns_my_tbl_my_column\"", + "UPDATE \"" + + METADATA_SCHEMA + + "\".\"metadata\" SET \"indexed\"=false WHERE \"full_table_name\"='my_ns.my_tbl' AND \"column_name\"='my_column'"); + } @Test public void dropIndex_forColumnTypeWithoutRequiredAlterationForServer_ShouldDropIndexProperly() @@ -2755,6 +3415,251 @@ private void dropIndex_forColumnTypeWithRequiredAlterationForX_ShouldDropIndexPr .isEqualTo(expectedUpdateTableMetadataStatement); } + @Test + public void dropIndex_VirtualTableWithColumnInLeftSourceTable_ShouldDropIndexOnLeftSourceTable() + throws Exception { + // Arrange + String namespace = "ns"; + String table = "vtable"; + String leftSourceNamespace = "ns"; + String leftSourceTable = "left_table"; + String rightSourceNamespace = "ns"; + String rightSourceTable = "right_table"; + String columnName = "col1"; + + when(dataSource.getConnection()).thenReturn(connection); + + VirtualTableInfo virtualTableInfo = mock(VirtualTableInfo.class); + when(virtualTableInfo.getLeftSourceNamespaceName()).thenReturn(leftSourceNamespace); + when(virtualTableInfo.getLeftSourceTableName()).thenReturn(leftSourceTable); + when(virtualTableInfo.getRightSourceNamespaceName()).thenReturn(rightSourceNamespace); + when(virtualTableInfo.getRightSourceTableName()).thenReturn(rightSourceTable); + + when(virtualTableMetadataService.getVirtualTableInfo(connection, namespace, table)) + .thenReturn(virtualTableInfo); + + TableMetadata leftSourceTableMetadata = + TableMetadata.newBuilder() + .addPartitionKey("pk1") + .addColumn("pk1", DataType.TEXT) + .addColumn("col1", DataType.INT) + .build(); + + TableMetadata rightSourceTableMetadata = + TableMetadata.newBuilder() + .addPartitionKey("pk1") + .addColumn("pk1", DataType.TEXT) + .addColumn("col2", DataType.INT) + .build(); + + when(tableMetadataService.getTableMetadata(connection, leftSourceNamespace, leftSourceTable)) + .thenReturn(leftSourceTableMetadata); + when(tableMetadataService.getTableMetadata(connection, rightSourceNamespace, rightSourceTable)) + .thenReturn(rightSourceTableMetadata); + + Statement statement = mock(Statement.class); + when(connection.createStatement()).thenReturn(statement); + + JdbcAdmin admin = createJdbcAdmin(); + + // Act + admin.dropIndex(namespace, table, columnName); + + // Assert + verify(virtualTableMetadataService) + .getVirtualTableInfo(eq(connection), eq(namespace), eq(table)); + verify(tableMetadataService) + .getTableMetadata(eq(connection), eq(leftSourceNamespace), eq(leftSourceTable)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(String.class); + verify(statement).execute(captor.capture()); + assertThat(captor.getValue()) + .isEqualTo("DROP INDEX `index_ns_left_table_col1` ON `ns`.`left_table`"); + verify(tableMetadataService) + .updateTableMetadata( + eq(connection), + eq(leftSourceNamespace), + eq(leftSourceTable), + eq(columnName), + eq(false)); + } + + @Test + public void dropIndex_VirtualTableWithColumnInRightSourceTable_ShouldDropIndexOnRightSourceTable() + throws Exception { + // Arrange + String namespace = "ns"; + String table = "vtable"; + String leftSourceNamespace = "ns"; + String leftSourceTable = "left_table"; + String rightSourceNamespace = "ns"; + String rightSourceTable = "right_table"; + String columnName = "col2"; + + when(dataSource.getConnection()).thenReturn(connection); + + VirtualTableInfo virtualTableInfo = mock(VirtualTableInfo.class); + when(virtualTableInfo.getLeftSourceNamespaceName()).thenReturn(leftSourceNamespace); + when(virtualTableInfo.getLeftSourceTableName()).thenReturn(leftSourceTable); + when(virtualTableInfo.getRightSourceNamespaceName()).thenReturn(rightSourceNamespace); + when(virtualTableInfo.getRightSourceTableName()).thenReturn(rightSourceTable); + + when(virtualTableMetadataService.getVirtualTableInfo(connection, namespace, table)) + .thenReturn(virtualTableInfo); + + TableMetadata leftSourceTableMetadata = + TableMetadata.newBuilder() + .addPartitionKey("pk1") + .addColumn("pk1", DataType.TEXT) + .addColumn("col1", DataType.INT) + .build(); + + TableMetadata rightSourceTableMetadata = + TableMetadata.newBuilder() + .addPartitionKey("pk1") + .addColumn("pk1", DataType.TEXT) + .addColumn("col2", DataType.INT) + .build(); + + when(tableMetadataService.getTableMetadata(connection, leftSourceNamespace, leftSourceTable)) + .thenReturn(leftSourceTableMetadata); + when(tableMetadataService.getTableMetadata(connection, rightSourceNamespace, rightSourceTable)) + .thenReturn(rightSourceTableMetadata); + + Statement statement = mock(Statement.class); + when(connection.createStatement()).thenReturn(statement); + + JdbcAdmin admin = createJdbcAdmin(); + + // Act + admin.dropIndex(namespace, table, columnName); + + // Assert + verify(virtualTableMetadataService) + .getVirtualTableInfo(eq(connection), eq(namespace), eq(table)); + verify(tableMetadataService) + .getTableMetadata(eq(connection), eq(leftSourceNamespace), eq(leftSourceTable)); + verify(tableMetadataService) + .getTableMetadata(eq(connection), eq(rightSourceNamespace), eq(rightSourceTable)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(String.class); + verify(statement).execute(captor.capture()); + assertThat(captor.getValue()) + .isEqualTo("DROP INDEX `index_ns_right_table_col2` ON `ns`.`right_table`"); + verify(tableMetadataService) + .updateTableMetadata( + eq(connection), + eq(rightSourceNamespace), + eq(rightSourceTable), + eq(columnName), + eq(false)); + } + + @Test + public void dropIndex_VirtualTableWithPrimaryKeyColumn_ShouldDropIndexOnBothSourceTables() + throws Exception { + // Arrange + String namespace = "ns"; + String table = "vtable"; + String leftSourceNamespace = "ns"; + String leftSourceTable = "left_table"; + String rightSourceNamespace = "ns"; + String rightSourceTable = "right_table"; + String columnName = "pk1"; + + when(dataSource.getConnection()).thenReturn(connection); + + VirtualTableInfo virtualTableInfo = mock(VirtualTableInfo.class); + when(virtualTableInfo.getLeftSourceNamespaceName()).thenReturn(leftSourceNamespace); + when(virtualTableInfo.getLeftSourceTableName()).thenReturn(leftSourceTable); + when(virtualTableInfo.getRightSourceNamespaceName()).thenReturn(rightSourceNamespace); + when(virtualTableInfo.getRightSourceTableName()).thenReturn(rightSourceTable); + + when(virtualTableMetadataService.getVirtualTableInfo(connection, namespace, table)) + .thenReturn(virtualTableInfo); + + TableMetadata leftSourceTableMetadata = + TableMetadata.newBuilder() + .addPartitionKey("pk1") + .addColumn("pk1", DataType.INT) + .addColumn("col1", DataType.INT) + .build(); + + TableMetadata rightSourceTableMetadata = + TableMetadata.newBuilder() + .addPartitionKey("pk1") + .addColumn("pk1", DataType.INT) + .addColumn("col2", DataType.INT) + .build(); + + when(tableMetadataService.getTableMetadata(connection, leftSourceNamespace, leftSourceTable)) + .thenReturn(leftSourceTableMetadata); + when(tableMetadataService.getTableMetadata(connection, rightSourceNamespace, rightSourceTable)) + .thenReturn(rightSourceTableMetadata); + + Statement statement = mock(Statement.class); + when(connection.createStatement()).thenReturn(statement); + + JdbcAdmin admin = createJdbcAdmin(); + + // Act + admin.dropIndex(namespace, table, columnName); + + // Assert + verify(virtualTableMetadataService) + .getVirtualTableInfo(eq(connection), eq(namespace), eq(table)); + verify(tableMetadataService) + .getTableMetadata(eq(connection), eq(leftSourceNamespace), eq(leftSourceTable)); + verify(tableMetadataService) + .getTableMetadata(eq(connection), eq(rightSourceNamespace), eq(rightSourceTable)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(String.class); + verify(statement, times(2)).execute(captor.capture()); + assertThat(captor.getAllValues()) + .containsExactly( + "DROP INDEX `index_ns_left_table_pk1` ON `ns`.`left_table`", + "DROP INDEX `index_ns_right_table_pk1` ON `ns`.`right_table`"); + verify(tableMetadataService) + .updateTableMetadata( + eq(connection), + eq(leftSourceNamespace), + eq(leftSourceTable), + eq(columnName), + eq(false)); + verify(tableMetadataService) + .updateTableMetadata( + eq(connection), + eq(rightSourceNamespace), + eq(rightSourceTable), + eq(columnName), + eq(false)); + } + + @Test + public void dropIndex_VirtualTableWithSQLException_ShouldThrowExecutionException() + throws Exception { + // Arrange + String namespace = "ns"; + String table = "vtable"; + String columnName = "col1"; + + when(dataSource.getConnection()).thenReturn(connection); + + SQLException sqlException = new SQLException("SQL error occurred"); + when(virtualTableMetadataService.getVirtualTableInfo(connection, namespace, table)) + .thenThrow(sqlException); + + JdbcAdmin admin = createJdbcAdmin(); + + // Act Assert + assertThatThrownBy(() -> admin.dropIndex(namespace, table, columnName)) + .isInstanceOf(ExecutionException.class) + .hasMessageContaining("Dropping the secondary index") + .hasMessageContaining(columnName) + .hasMessageContaining(getFullTableName(namespace, table)) + .hasCause(sqlException); + } + @Test public void addNewColumnToTable_ForMysql_ShouldWorkProperly() throws SQLException, ExecutionException { @@ -4183,6 +5088,392 @@ void createIndex_Oracle_WithBlobColumnAsKeyOrIndex_ShouldThrowUnsupportedOperati .hasMessageContainingAll("BLOB", "index"); } + @SuppressFBWarnings("SQL_NONCONSTANT_STRING_PASSED_TO_EXECUTE") + @Test + public void createVirtualTable_WithInnerJoin_ShouldCreateViewWithInnerJoin() throws Exception { + // Arrange + String namespace = "ns"; + String table = "vtable"; + String leftSourceNamespace = "ns"; + String leftSourceTable = "left_table"; + String rightSourceNamespace = "ns"; + String rightSourceTable = "right_table"; + VirtualTableJoinType joinType = VirtualTableJoinType.INNER; + + TableMetadata leftSourceTableMetadata = + TableMetadata.newBuilder() + .addPartitionKey("pk1") + .addClusteringKey("ck1", Order.ASC) + .addColumn("pk1", DataType.INT) + .addColumn("ck1", DataType.TEXT) + .addColumn("col1", DataType.INT) + .build(); + + TableMetadata rightSourceTableMetadata = + TableMetadata.newBuilder() + .addPartitionKey("pk1") + .addClusteringKey("ck1", Order.ASC) + .addColumn("pk1", DataType.INT) + .addColumn("ck1", DataType.TEXT) + .addColumn("col2", DataType.TEXT) + .build(); + + when(tableMetadataService.getTableMetadata( + any(Connection.class), eq(leftSourceNamespace), eq(leftSourceTable))) + .thenReturn(leftSourceTableMetadata); + when(tableMetadataService.getTableMetadata( + any(Connection.class), eq(rightSourceNamespace), eq(rightSourceTable))) + .thenReturn(rightSourceTableMetadata); + + Statement statement = mock(Statement.class); + when(dataSource.getConnection()).thenReturn(connection); + when(connection.createStatement()).thenReturn(statement); + doNothing() + .when(virtualTableMetadataService) + .createVirtualTablesTableIfNotExists(any(Connection.class)); + doNothing() + .when(virtualTableMetadataService) + .insertIntoVirtualTablesTable( + any(Connection.class), + anyString(), + anyString(), + anyString(), + anyString(), + anyString(), + anyString(), + any(VirtualTableJoinType.class), + anyString()); + + ArgumentCaptor sqlCaptor = ArgumentCaptor.forClass(String.class); + + // Build expected SQL (MySQL) + RdbEngineStrategy rdbEngineStrategy = getRdbEngineStrategy(RdbEngine.MYSQL); + String expectedSql = + "CREATE VIEW " + + rdbEngineStrategy.encloseFullTableName(namespace, table) + + " AS SELECT t1." + + rdbEngineStrategy.enclose("pk1") + + " AS " + + rdbEngineStrategy.enclose("pk1") + + ", t1." + + rdbEngineStrategy.enclose("ck1") + + " AS " + + rdbEngineStrategy.enclose("ck1") + + ", t1." + + rdbEngineStrategy.enclose("col1") + + " AS " + + rdbEngineStrategy.enclose("col1") + + ", t2." + + rdbEngineStrategy.enclose("col2") + + " AS " + + rdbEngineStrategy.enclose("col2") + + " FROM " + + rdbEngineStrategy.encloseFullTableName(leftSourceNamespace, leftSourceTable) + + " t1 INNER JOIN " + + rdbEngineStrategy.encloseFullTableName(rightSourceNamespace, rightSourceTable) + + " t2 ON t1." + + rdbEngineStrategy.enclose("pk1") + + " = t2." + + rdbEngineStrategy.enclose("pk1") + + " AND t1." + + rdbEngineStrategy.enclose("ck1") + + " = t2." + + rdbEngineStrategy.enclose("ck1"); + + JdbcAdmin admin = createJdbcAdmin(); + JdbcAdmin spyAdmin = spy(admin); + doNothing().when(spyAdmin).createMetadataSchemaIfNotExists(any(Connection.class)); + + // Act + spyAdmin.createVirtualTable( + namespace, + table, + leftSourceNamespace, + leftSourceTable, + rightSourceNamespace, + rightSourceTable, + joinType, + Collections.emptyMap()); + + // Assert + verify(statement).execute(sqlCaptor.capture()); + String executedSql = sqlCaptor.getValue(); + assertThat(executedSql).isEqualTo(expectedSql); + verify(spyAdmin).createMetadataSchemaIfNotExists(eq(connection)); + verify(virtualTableMetadataService).createVirtualTablesTableIfNotExists(eq(connection)); + verify(virtualTableMetadataService) + .insertIntoVirtualTablesTable( + any(Connection.class), + eq(namespace), + eq(table), + eq(leftSourceNamespace), + eq(leftSourceTable), + eq(rightSourceNamespace), + eq(rightSourceTable), + eq(joinType), + eq("")); + } + + @SuppressFBWarnings("SQL_NONCONSTANT_STRING_PASSED_TO_EXECUTE") + @Test + public void createVirtualTable_WithLeftOuterJoin_ShouldCreateViewWithLeftOuterJoin() + throws Exception { + // Arrange + String namespace = "ns"; + String table = "vtable"; + String leftSourceNamespace = "ns"; + String leftSourceTable = "left_table"; + String rightSourceNamespace = "ns"; + String rightSourceTable = "right_table"; + VirtualTableJoinType joinType = VirtualTableJoinType.LEFT_OUTER; + + TableMetadata leftSourceTableMetadata = + TableMetadata.newBuilder() + .addPartitionKey("pk1") + .addClusteringKey("ck1", Order.ASC) + .addColumn("pk1", DataType.INT) + .addColumn("ck1", DataType.TEXT) + .addColumn("col1", DataType.INT) + .build(); + + TableMetadata rightSourceTableMetadata = + TableMetadata.newBuilder() + .addPartitionKey("pk1") + .addClusteringKey("ck1", Order.ASC) + .addColumn("pk1", DataType.INT) + .addColumn("ck1", DataType.TEXT) + .addColumn("col2", DataType.TEXT) + .build(); + + when(tableMetadataService.getTableMetadata( + any(Connection.class), eq(leftSourceNamespace), eq(leftSourceTable))) + .thenReturn(leftSourceTableMetadata); + when(tableMetadataService.getTableMetadata( + any(Connection.class), eq(rightSourceNamespace), eq(rightSourceTable))) + .thenReturn(rightSourceTableMetadata); + + Statement statement = mock(Statement.class); + when(dataSource.getConnection()).thenReturn(connection); + when(connection.createStatement()).thenReturn(statement); + doNothing() + .when(virtualTableMetadataService) + .createVirtualTablesTableIfNotExists(any(Connection.class)); + doNothing() + .when(virtualTableMetadataService) + .insertIntoVirtualTablesTable( + any(Connection.class), + anyString(), + anyString(), + anyString(), + anyString(), + anyString(), + anyString(), + any(VirtualTableJoinType.class), + anyString()); + + ArgumentCaptor sqlCaptor = ArgumentCaptor.forClass(String.class); + + // Build expected SQL (MySQL) + RdbEngineStrategy rdbEngineStrategy = getRdbEngineStrategy(RdbEngine.MYSQL); + String expectedSql = + "CREATE VIEW " + + rdbEngineStrategy.encloseFullTableName(namespace, table) + + " AS SELECT t1." + + rdbEngineStrategy.enclose("pk1") + + " AS " + + rdbEngineStrategy.enclose("pk1") + + ", t1." + + rdbEngineStrategy.enclose("ck1") + + " AS " + + rdbEngineStrategy.enclose("ck1") + + ", t1." + + rdbEngineStrategy.enclose("col1") + + " AS " + + rdbEngineStrategy.enclose("col1") + + ", t2." + + rdbEngineStrategy.enclose("col2") + + " AS " + + rdbEngineStrategy.enclose("col2") + + " FROM " + + rdbEngineStrategy.encloseFullTableName(leftSourceNamespace, leftSourceTable) + + " t1 LEFT OUTER JOIN " + + rdbEngineStrategy.encloseFullTableName(rightSourceNamespace, rightSourceTable) + + " t2 ON t1." + + rdbEngineStrategy.enclose("pk1") + + " = t2." + + rdbEngineStrategy.enclose("pk1") + + " AND t1." + + rdbEngineStrategy.enclose("ck1") + + " = t2." + + rdbEngineStrategy.enclose("ck1"); + + JdbcAdmin admin = createJdbcAdmin(); + JdbcAdmin spyAdmin = spy(admin); + doNothing().when(spyAdmin).createMetadataSchemaIfNotExists(any(Connection.class)); + + // Act + spyAdmin.createVirtualTable( + namespace, + table, + leftSourceNamespace, + leftSourceTable, + rightSourceNamespace, + rightSourceTable, + joinType, + Collections.emptyMap()); + + // Assert + verify(statement).execute(sqlCaptor.capture()); + String executedSql = sqlCaptor.getValue(); + assertThat(executedSql).isEqualTo(expectedSql); + verify(spyAdmin).createMetadataSchemaIfNotExists(eq(connection)); + verify(virtualTableMetadataService).createVirtualTablesTableIfNotExists(eq(connection)); + verify(virtualTableMetadataService) + .insertIntoVirtualTablesTable( + eq(connection), + eq(namespace), + eq(table), + eq(leftSourceNamespace), + eq(leftSourceTable), + eq(rightSourceNamespace), + eq(rightSourceTable), + eq(joinType), + eq("")); + } + + @Test + public void createVirtualTable_SQLExceptionThrown_ShouldThrowExecutionException() + throws Exception { + // Arrange + String namespace = "ns"; + String table = "vtable"; + String leftSourceNamespace = "ns"; + String leftSourceTable = "left_table"; + String rightSourceNamespace = "ns"; + String rightSourceTable = "right_table"; + VirtualTableJoinType joinType = VirtualTableJoinType.INNER; + + Statement statement = mock(Statement.class); + when(dataSource.getConnection()).thenReturn(connection); + when(connection.createStatement()).thenReturn(statement); + + TableMetadata leftSourceTableMetadata = + TableMetadata.newBuilder() + .addPartitionKey("pk1") + .addClusteringKey("ck1") + .addColumn("pk1", DataType.TEXT) + .addColumn("ck1", DataType.TEXT) + .addColumn("col1", DataType.TEXT) + .build(); + + TableMetadata rightSourceTableMetadata = + TableMetadata.newBuilder() + .addPartitionKey("pk1") + .addClusteringKey("ck1") + .addColumn("pk1", DataType.TEXT) + .addColumn("ck1", DataType.TEXT) + .addColumn("col2", DataType.TEXT) + .build(); + + when(tableMetadataService.getTableMetadata( + any(Connection.class), eq(leftSourceNamespace), eq(leftSourceTable))) + .thenReturn(leftSourceTableMetadata); + when(tableMetadataService.getTableMetadata( + any(Connection.class), eq(rightSourceNamespace), eq(rightSourceTable))) + .thenReturn(rightSourceTableMetadata); + + SQLException sqlException = new SQLException("SQL error occurred"); + when(statement.execute(anyString())).thenThrow(sqlException); + + JdbcAdmin admin = createJdbcAdmin(); + + // Act Assert + assertThatThrownBy( + () -> + admin.createVirtualTable( + namespace, + table, + leftSourceNamespace, + leftSourceTable, + rightSourceNamespace, + rightSourceTable, + joinType, + Collections.emptyMap())) + .isInstanceOf(ExecutionException.class) + .hasCause(sqlException); + } + + @Test + public void getVirtualTableInfo_VirtualTableExists_ShouldReturnVirtualTableInfo() + throws Exception { + // Arrange + String namespace = "ns"; + String table = "vtable"; + + when(dataSource.getConnection()).thenReturn(connection); + + VirtualTableInfo expectedVirtualTableInfo = mock(VirtualTableInfo.class); + + when(virtualTableMetadataService.getVirtualTableInfo(connection, namespace, table)) + .thenReturn(expectedVirtualTableInfo); + + JdbcAdmin admin = createJdbcAdmin(); + + // Act + Optional result = admin.getVirtualTableInfo(namespace, table); + + // Assert + assertThat(result).isPresent(); + assertThat(result.get()).isEqualTo(expectedVirtualTableInfo); + verify(virtualTableMetadataService) + .getVirtualTableInfo(eq(connection), eq(namespace), eq(table)); + } + + @Test + public void getVirtualTableInfo_VirtualTableDoesNotExist_ShouldReturnEmptyOptional() + throws Exception { + // Arrange + String namespace = "ns"; + String table = "vtable"; + + when(dataSource.getConnection()).thenReturn(connection); + + when(virtualTableMetadataService.getVirtualTableInfo(connection, namespace, table)) + .thenReturn(null); + + JdbcAdmin admin = createJdbcAdmin(); + + // Act + Optional result = admin.getVirtualTableInfo(namespace, table); + + // Assert + assertThat(result).isEmpty(); + verify(virtualTableMetadataService) + .getVirtualTableInfo(eq(connection), eq(namespace), eq(table)); + } + + @Test + public void getVirtualTableInfo_SQLExceptionThrown_ShouldThrowExecutionException() + throws Exception { + // Arrange + String namespace = "ns"; + String table = "vtable"; + + when(dataSource.getConnection()).thenReturn(connection); + + SQLException sqlException = new SQLException("SQL error occurred"); + + when(virtualTableMetadataService.getVirtualTableInfo(connection, namespace, table)) + .thenThrow(sqlException); + + JdbcAdmin admin = createJdbcAdmin(); + + // Act Assert + assertThatThrownBy(() -> admin.getVirtualTableInfo(namespace, table)) + .isInstanceOf(ExecutionException.class) + .hasCause(sqlException); + } + // Utility class used to mock ResultSet for a "select * from" query on the metadata table static class SelectAllFromMetadataTableResultSetMocker implements org.mockito.stubbing.Answer { diff --git a/core/src/test/java/com/scalar/db/storage/jdbc/JdbcDatabaseTest.java b/core/src/test/java/com/scalar/db/storage/jdbc/JdbcDatabaseTest.java index 7c32566f79..4de7866a94 100644 --- a/core/src/test/java/com/scalar/db/storage/jdbc/JdbcDatabaseTest.java +++ b/core/src/test/java/com/scalar/db/storage/jdbc/JdbcDatabaseTest.java @@ -1,5 +1,6 @@ package com.scalar.db.storage.jdbc; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; @@ -9,15 +10,23 @@ import static org.mockito.Mockito.when; import com.scalar.db.api.ConditionBuilder; +import com.scalar.db.api.Consistency; import com.scalar.db.api.Delete; import com.scalar.db.api.Get; +import com.scalar.db.api.Mutation; import com.scalar.db.api.Put; import com.scalar.db.api.Scan; import com.scalar.db.api.Scanner; +import com.scalar.db.api.TableMetadata; +import com.scalar.db.api.VirtualTableInfo; +import com.scalar.db.api.VirtualTableJoinType; +import com.scalar.db.common.TableMetadataManager; +import com.scalar.db.common.VirtualTableInfoManager; import com.scalar.db.config.DatabaseConfig; import com.scalar.db.exception.storage.ExecutionException; import com.scalar.db.exception.storage.NoMutationException; import com.scalar.db.exception.storage.RetriableExecutionException; +import com.scalar.db.io.DataType; import com.scalar.db.io.Key; import java.io.IOException; import java.sql.Connection; @@ -25,9 +34,11 @@ import java.sql.ResultSet; import java.sql.SQLException; import java.util.Arrays; +import java.util.List; import org.apache.commons.dbcp2.BasicDataSource; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.MockitoAnnotations; @@ -39,6 +50,8 @@ public class JdbcDatabaseTest { @Mock private DatabaseConfig databaseConfig; @Mock private BasicDataSource dataSource; @Mock private BasicDataSource tableMetadataDataSource; + @Mock private TableMetadataManager tableMetadataManager; + @Mock private VirtualTableInfoManager virtualTableInfoManager; @Mock private JdbcService jdbcService; @Mock private ResultInterpreter resultInterpreter; @@ -59,9 +72,11 @@ public void setUp() throws Exception { jdbcDatabase = new JdbcDatabase( databaseConfig, + RdbEngine.createRdbEngineStrategy(RdbEngine.POSTGRESQL), dataSource, tableMetadataDataSource, - RdbEngine.createRdbEngineStrategy(RdbEngine.POSTGRESQL), + tableMetadataManager, + virtualTableInfoManager, jdbcService); } @@ -504,4 +519,699 @@ public void mutate_WhenSettingAutoCommitFails_ShouldThrowExceptionAndCloseConnec verify(connection, never()).rollback(); verify(connection).close(); } + + @Test + public void + put_ForVirtualTableWithInnerJoin_ShouldDivideIntoSourceTablesAndCallJdbcServiceWithBothPuts() + throws Exception { + // Arrange + VirtualTableInfo virtualTableInfo = createVirtualTableInfo(VirtualTableJoinType.INNER); + when(virtualTableInfoManager.getVirtualTableInfo(NAMESPACE, TABLE)) + .thenReturn(virtualTableInfo); + when(tableMetadataManager.getTableMetadata("left_ns", "left_table")) + .thenReturn(createLeftSourceTableMetadata()); + when(tableMetadataManager.getTableMetadata("right_ns", "right_table")) + .thenReturn(createRightSourceTableMetadata()); + when(jdbcService.mutate(any(), any())).thenReturn(true); + + Put put = + Put.newBuilder() + .namespace(NAMESPACE) + .table(TABLE) + .partitionKey(Key.ofText("pk1", "val1")) + .clusteringKey(Key.ofText("ck1", "ck_val1")) + .textValue("left_col1", "left_val1") + .intValue("left_col2", 100) + .textValue("right_col1", "right_val1") + .intValue("right_col2", 200) + .consistency(Consistency.LINEARIZABLE) + .build(); + + // Act + jdbcDatabase.put(put); + + // Assert + Put expectedLeftPut = + Put.newBuilder() + .namespace("left_ns") + .table("left_table") + .partitionKey(Key.ofText("pk1", "val1")) + .clusteringKey(Key.ofText("ck1", "ck_val1")) + .textValue("left_col1", "left_val1") + .intValue("left_col2", 100) + .consistency(Consistency.LINEARIZABLE) + .build(); + + Put expectedRightPut = + Put.newBuilder() + .namespace("right_ns") + .table("right_table") + .partitionKey(Key.ofText("pk1", "val1")) + .clusteringKey(Key.ofText("ck1", "ck_val1")) + .textValue("right_col1", "right_val1") + .intValue("right_col2", 200) + .consistency(Consistency.LINEARIZABLE) + .build(); + + @SuppressWarnings("unchecked") + ArgumentCaptor> mutationsCaptor = ArgumentCaptor.forClass(List.class); + verify(jdbcService).mutate(mutationsCaptor.capture(), any()); + + List mutations = mutationsCaptor.getValue(); + assertThat(mutations).hasSize(2); + assertThat(mutations.get(0)).isEqualTo(expectedLeftPut); + assertThat(mutations.get(1)).isEqualTo(expectedRightPut); + + verify(connection).close(); + } + + @Test + public void + put_ForVirtualTableWithPutIfExistsAndInnerJoin_ShouldApplyConditionToBothSourceTables() + throws Exception { + // Arrange + VirtualTableInfo virtualTableInfo = createVirtualTableInfo(VirtualTableJoinType.INNER); + when(virtualTableInfoManager.getVirtualTableInfo(NAMESPACE, TABLE)) + .thenReturn(virtualTableInfo); + when(tableMetadataManager.getTableMetadata("left_ns", "left_table")) + .thenReturn(createLeftSourceTableMetadata()); + when(tableMetadataManager.getTableMetadata("right_ns", "right_table")) + .thenReturn(createRightSourceTableMetadata()); + when(jdbcService.mutate(any(), any())).thenReturn(true); + + Put put = + Put.newBuilder() + .namespace(NAMESPACE) + .table(TABLE) + .partitionKey(Key.ofText("pk1", "val1")) + .clusteringKey(Key.ofText("ck1", "ck_val1")) + .textValue("left_col1", "left_val1") + .textValue("right_col1", "right_val1") + .condition(ConditionBuilder.putIfExists()) + .consistency(Consistency.LINEARIZABLE) + .build(); + + // Act + jdbcDatabase.put(put); + + // Assert + Put expectedLeftPut = + Put.newBuilder() + .namespace("left_ns") + .table("left_table") + .partitionKey(Key.ofText("pk1", "val1")) + .clusteringKey(Key.ofText("ck1", "ck_val1")) + .textValue("left_col1", "left_val1") + .condition(ConditionBuilder.putIfExists()) + .consistency(Consistency.LINEARIZABLE) + .build(); + + Put expectedRightPut = + Put.newBuilder() + .namespace("right_ns") + .table("right_table") + .partitionKey(Key.ofText("pk1", "val1")) + .clusteringKey(Key.ofText("ck1", "ck_val1")) + .textValue("right_col1", "right_val1") + .condition(ConditionBuilder.putIfExists()) + .consistency(Consistency.LINEARIZABLE) + .build(); + + @SuppressWarnings("unchecked") + ArgumentCaptor> mutationsCaptor = ArgumentCaptor.forClass(List.class); + verify(jdbcService).mutate(mutationsCaptor.capture(), any()); + + List mutations = mutationsCaptor.getValue(); + assertThat(mutations).hasSize(2); + assertThat(mutations.get(0)).isEqualTo(expectedLeftPut); + assertThat(mutations.get(1)).isEqualTo(expectedRightPut); + + verify(connection).close(); + } + + @Test + public void + put_ForVirtualTableWithPutIfExistsAndLeftOuterJoin_ShouldApplyConditionToLeftSourceTableOnly() + throws Exception { + // Arrange + VirtualTableInfo virtualTableInfo = createVirtualTableInfo(VirtualTableJoinType.LEFT_OUTER); + when(virtualTableInfoManager.getVirtualTableInfo(NAMESPACE, TABLE)) + .thenReturn(virtualTableInfo); + when(tableMetadataManager.getTableMetadata("left_ns", "left_table")) + .thenReturn(createLeftSourceTableMetadata()); + when(tableMetadataManager.getTableMetadata("right_ns", "right_table")) + .thenReturn(createRightSourceTableMetadata()); + when(jdbcService.mutate(any(), any())).thenReturn(true); + + Put put = + Put.newBuilder() + .namespace(NAMESPACE) + .table(TABLE) + .partitionKey(Key.ofText("pk1", "val1")) + .clusteringKey(Key.ofText("ck1", "ck_val1")) + .textValue("left_col1", "left_val1") + .textValue("right_col1", "right_val1") + .condition(ConditionBuilder.putIfExists()) + .consistency(Consistency.LINEARIZABLE) + .build(); + + // Act + jdbcDatabase.put(put); + + // Assert + Put expectedLeftPut = + Put.newBuilder() + .namespace("left_ns") + .table("left_table") + .partitionKey(Key.ofText("pk1", "val1")) + .clusteringKey(Key.ofText("ck1", "ck_val1")) + .textValue("left_col1", "left_val1") + .condition(ConditionBuilder.putIfExists()) + .consistency(Consistency.LINEARIZABLE) + .build(); + + Put expectedRightPut = + Put.newBuilder() + .namespace("right_ns") + .table("right_table") + .partitionKey(Key.ofText("pk1", "val1")) + .clusteringKey(Key.ofText("ck1", "ck_val1")) + .textValue("right_col1", "right_val1") + .consistency(Consistency.LINEARIZABLE) + .build(); + + @SuppressWarnings("unchecked") + ArgumentCaptor> mutationsCaptor = ArgumentCaptor.forClass(List.class); + verify(jdbcService).mutate(mutationsCaptor.capture(), any()); + + List mutations = mutationsCaptor.getValue(); + assertThat(mutations).hasSize(2); + assertThat(mutations.get(0)).isEqualTo(expectedLeftPut); + assertThat(mutations.get(1)).isEqualTo(expectedRightPut); + + verify(connection).close(); + } + + @Test + public void put_ForVirtualTableWithPutIf_ShouldDivideConditionsBasedOnColumns() throws Exception { + // Arrange + VirtualTableInfo virtualTableInfo = createVirtualTableInfo(VirtualTableJoinType.INNER); + when(virtualTableInfoManager.getVirtualTableInfo(NAMESPACE, TABLE)) + .thenReturn(virtualTableInfo); + when(tableMetadataManager.getTableMetadata("left_ns", "left_table")) + .thenReturn(createLeftSourceTableMetadata()); + when(tableMetadataManager.getTableMetadata("right_ns", "right_table")) + .thenReturn(createRightSourceTableMetadata()); + when(jdbcService.mutate(any(), any())).thenReturn(true); + + Put put = + Put.newBuilder() + .namespace(NAMESPACE) + .table(TABLE) + .partitionKey(Key.ofText("pk1", "val1")) + .clusteringKey(Key.ofText("ck1", "ck_val1")) + .textValue("left_col1", "left_val1") + .textValue("right_col1", "right_val1") + .condition( + ConditionBuilder.putIf( + ConditionBuilder.column("left_col1").isEqualToText("check_val")) + .and(ConditionBuilder.column("right_col1").isEqualToText("check_val2")) + .build()) + .consistency(Consistency.LINEARIZABLE) + .build(); + + // Act + jdbcDatabase.put(put); + + // Assert + Put expectedLeftPut = + Put.newBuilder() + .namespace("left_ns") + .table("left_table") + .partitionKey(Key.ofText("pk1", "val1")) + .clusteringKey(Key.ofText("ck1", "ck_val1")) + .textValue("left_col1", "left_val1") + .condition( + ConditionBuilder.putIf( + ConditionBuilder.column("left_col1").isEqualToText("check_val")) + .build()) + .consistency(Consistency.LINEARIZABLE) + .build(); + + Put expectedRightPut = + Put.newBuilder() + .namespace("right_ns") + .table("right_table") + .partitionKey(Key.ofText("pk1", "val1")) + .clusteringKey(Key.ofText("ck1", "ck_val1")) + .textValue("right_col1", "right_val1") + .condition( + ConditionBuilder.putIf( + ConditionBuilder.column("right_col1").isEqualToText("check_val2")) + .build()) + .consistency(Consistency.LINEARIZABLE) + .build(); + + @SuppressWarnings("unchecked") + ArgumentCaptor> mutationsCaptor = ArgumentCaptor.forClass(List.class); + verify(jdbcService).mutate(mutationsCaptor.capture(), any()); + + List mutations = mutationsCaptor.getValue(); + assertThat(mutations).hasSize(2); + assertThat(mutations.get(0)).isEqualTo(expectedLeftPut); + assertThat(mutations.get(1)).isEqualTo(expectedRightPut); + + verify(connection).close(); + } + + @Test + public void + delete_ForVirtualTableWithDeleteIfExistsAndInnerJoin_ShouldApplyConditionToBothSourceTables() + throws Exception { + // Arrange + VirtualTableInfo virtualTableInfo = createVirtualTableInfo(VirtualTableJoinType.INNER); + when(virtualTableInfoManager.getVirtualTableInfo(NAMESPACE, TABLE)) + .thenReturn(virtualTableInfo); + when(tableMetadataManager.getTableMetadata("left_ns", "left_table")) + .thenReturn(createLeftSourceTableMetadata()); + when(tableMetadataManager.getTableMetadata("right_ns", "right_table")) + .thenReturn(createRightSourceTableMetadata()); + when(jdbcService.mutate(any(), any())).thenReturn(true); + + Delete delete = + Delete.newBuilder() + .namespace(NAMESPACE) + .table(TABLE) + .partitionKey(Key.ofText("pk1", "val1")) + .clusteringKey(Key.ofText("ck1", "ck_val1")) + .condition(ConditionBuilder.deleteIfExists()) + .consistency(Consistency.LINEARIZABLE) + .build(); + + // Act + jdbcDatabase.delete(delete); + + // Assert + Delete expectedLeftDelete = + Delete.newBuilder() + .namespace("left_ns") + .table("left_table") + .partitionKey(Key.ofText("pk1", "val1")) + .clusteringKey(Key.ofText("ck1", "ck_val1")) + .condition(ConditionBuilder.deleteIfExists()) + .consistency(Consistency.LINEARIZABLE) + .build(); + + Delete expectedRightDelete = + Delete.newBuilder() + .namespace("right_ns") + .table("right_table") + .partitionKey(Key.ofText("pk1", "val1")) + .clusteringKey(Key.ofText("ck1", "ck_val1")) + .condition(ConditionBuilder.deleteIfExists()) + .consistency(Consistency.LINEARIZABLE) + .build(); + + @SuppressWarnings("unchecked") + ArgumentCaptor> mutationsCaptor = ArgumentCaptor.forClass(List.class); + verify(jdbcService).mutate(mutationsCaptor.capture(), any()); + + List mutations = mutationsCaptor.getValue(); + assertThat(mutations).hasSize(2); + assertThat(mutations.get(0)).isEqualTo(expectedLeftDelete); + assertThat(mutations.get(1)).isEqualTo(expectedRightDelete); + + verify(connection).close(); + } + + @Test + public void + delete_ForVirtualTableWithDeleteIfExistsAndLeftOuterJoin_ShouldApplyConditionToLeftSourceTableOnly() + throws Exception { + // Arrange + VirtualTableInfo virtualTableInfo = createVirtualTableInfo(VirtualTableJoinType.LEFT_OUTER); + when(virtualTableInfoManager.getVirtualTableInfo(NAMESPACE, TABLE)) + .thenReturn(virtualTableInfo); + when(tableMetadataManager.getTableMetadata("left_ns", "left_table")) + .thenReturn(createLeftSourceTableMetadata()); + when(tableMetadataManager.getTableMetadata("right_ns", "right_table")) + .thenReturn(createRightSourceTableMetadata()); + when(jdbcService.mutate(any(), any())).thenReturn(true); + + Delete delete = + Delete.newBuilder() + .namespace(NAMESPACE) + .table(TABLE) + .partitionKey(Key.ofText("pk1", "val1")) + .clusteringKey(Key.ofText("ck1", "ck_val1")) + .condition(ConditionBuilder.deleteIfExists()) + .consistency(Consistency.LINEARIZABLE) + .build(); + + // Act + jdbcDatabase.delete(delete); + + // Assert + Delete expectedLeftDelete = + Delete.newBuilder() + .namespace("left_ns") + .table("left_table") + .partitionKey(Key.ofText("pk1", "val1")) + .clusteringKey(Key.ofText("ck1", "ck_val1")) + .condition(ConditionBuilder.deleteIfExists()) + .consistency(Consistency.LINEARIZABLE) + .build(); + + Delete expectedRightDelete = + Delete.newBuilder() + .namespace("right_ns") + .table("right_table") + .partitionKey(Key.ofText("pk1", "val1")) + .clusteringKey(Key.ofText("ck1", "ck_val1")) + .consistency(Consistency.LINEARIZABLE) + .build(); + + @SuppressWarnings("unchecked") + ArgumentCaptor> mutationsCaptor = ArgumentCaptor.forClass(List.class); + verify(jdbcService).mutate(mutationsCaptor.capture(), any()); + + List mutations = mutationsCaptor.getValue(); + assertThat(mutations).hasSize(2); + assertThat(mutations.get(0)).isEqualTo(expectedLeftDelete); + assertThat(mutations.get(1)).isEqualTo(expectedRightDelete); + + verify(connection).close(); + } + + @Test + public void delete_ForVirtualTableWithDeleteIf_ShouldDivideConditionsBasedOnColumns() + throws Exception { + // Arrange + VirtualTableInfo virtualTableInfo = createVirtualTableInfo(VirtualTableJoinType.INNER); + when(virtualTableInfoManager.getVirtualTableInfo(NAMESPACE, TABLE)) + .thenReturn(virtualTableInfo); + when(tableMetadataManager.getTableMetadata("left_ns", "left_table")) + .thenReturn(createLeftSourceTableMetadata()); + when(tableMetadataManager.getTableMetadata("right_ns", "right_table")) + .thenReturn(createRightSourceTableMetadata()); + when(jdbcService.mutate(any(), any())).thenReturn(true); + + Delete delete = + Delete.newBuilder() + .namespace(NAMESPACE) + .table(TABLE) + .partitionKey(Key.ofText("pk1", "val1")) + .clusteringKey(Key.ofText("ck1", "ck_val1")) + .condition( + ConditionBuilder.deleteIf( + ConditionBuilder.column("left_col1").isEqualToText("check_val")) + .and(ConditionBuilder.column("right_col1").isEqualToText("check_val2")) + .build()) + .consistency(Consistency.LINEARIZABLE) + .build(); + + // Act + jdbcDatabase.delete(delete); + + // Assert + Delete expectedLeftDelete = + Delete.newBuilder() + .namespace("left_ns") + .table("left_table") + .partitionKey(Key.ofText("pk1", "val1")) + .clusteringKey(Key.ofText("ck1", "ck_val1")) + .condition( + ConditionBuilder.deleteIf( + ConditionBuilder.column("left_col1").isEqualToText("check_val")) + .build()) + .consistency(Consistency.LINEARIZABLE) + .build(); + + Delete expectedRightDelete = + Delete.newBuilder() + .namespace("right_ns") + .table("right_table") + .partitionKey(Key.ofText("pk1", "val1")) + .clusteringKey(Key.ofText("ck1", "ck_val1")) + .condition( + ConditionBuilder.deleteIf( + ConditionBuilder.column("right_col1").isEqualToText("check_val2")) + .build()) + .consistency(Consistency.LINEARIZABLE) + .build(); + + @SuppressWarnings("unchecked") + ArgumentCaptor> mutationsCaptor = ArgumentCaptor.forClass(List.class); + verify(jdbcService).mutate(mutationsCaptor.capture(), any()); + + List mutations = mutationsCaptor.getValue(); + assertThat(mutations).hasSize(2); + assertThat(mutations.get(0)).isEqualTo(expectedLeftDelete); + assertThat(mutations.get(1)).isEqualTo(expectedRightDelete); + + verify(connection).close(); + } + + @Test + public void mutate_ForVirtualTableWithPutAndDelete_ShouldDivideIntoSourceTables() + throws Exception { + // Arrange + VirtualTableInfo virtualTableInfo = createVirtualTableInfo(VirtualTableJoinType.INNER); + when(virtualTableInfoManager.getVirtualTableInfo(NAMESPACE, TABLE)) + .thenReturn(virtualTableInfo); + when(tableMetadataManager.getTableMetadata("left_ns", "left_table")) + .thenReturn(createLeftSourceTableMetadata()); + when(tableMetadataManager.getTableMetadata("right_ns", "right_table")) + .thenReturn(createRightSourceTableMetadata()); + when(jdbcService.mutate(any(), any())).thenReturn(true); + + Put put = + Put.newBuilder() + .namespace(NAMESPACE) + .table(TABLE) + .partitionKey(Key.ofText("pk1", "val1")) + .clusteringKey(Key.ofText("ck1", "ck_val1")) + .textValue("left_col1", "left_val1") + .textValue("right_col1", "right_val1") + .consistency(Consistency.LINEARIZABLE) + .build(); + + Delete delete = + Delete.newBuilder() + .namespace(NAMESPACE) + .table(TABLE) + .partitionKey(Key.ofText("pk1", "val2")) + .clusteringKey(Key.ofText("ck1", "ck_val2")) + .consistency(Consistency.LINEARIZABLE) + .build(); + + // Act + jdbcDatabase.mutate(Arrays.asList(put, delete)); + + // Assert + Put expectedLeftPut = + Put.newBuilder() + .namespace("left_ns") + .table("left_table") + .partitionKey(Key.ofText("pk1", "val1")) + .clusteringKey(Key.ofText("ck1", "ck_val1")) + .textValue("left_col1", "left_val1") + .consistency(Consistency.LINEARIZABLE) + .build(); + + Put expectedRightPut = + Put.newBuilder() + .namespace("right_ns") + .table("right_table") + .partitionKey(Key.ofText("pk1", "val1")) + .clusteringKey(Key.ofText("ck1", "ck_val1")) + .textValue("right_col1", "right_val1") + .consistency(Consistency.LINEARIZABLE) + .build(); + + Delete expectedLeftDelete = + Delete.newBuilder() + .namespace("left_ns") + .table("left_table") + .partitionKey(Key.ofText("pk1", "val2")) + .clusteringKey(Key.ofText("ck1", "ck_val2")) + .consistency(Consistency.LINEARIZABLE) + .build(); + + Delete expectedRightDelete = + Delete.newBuilder() + .namespace("right_ns") + .table("right_table") + .partitionKey(Key.ofText("pk1", "val2")) + .clusteringKey(Key.ofText("ck1", "ck_val2")) + .consistency(Consistency.LINEARIZABLE) + .build(); + + @SuppressWarnings("unchecked") + ArgumentCaptor> mutationsCaptor = ArgumentCaptor.forClass(List.class); + verify(jdbcService).mutate(mutationsCaptor.capture(), any()); + + List mutations = mutationsCaptor.getValue(); + // 2 Puts (from Put divided) + 2 Deletes (from Delete divided) = 4 mutations + assertThat(mutations).hasSize(4); + assertThat(mutations.get(0)).isEqualTo(expectedLeftPut); + assertThat(mutations.get(1)).isEqualTo(expectedRightPut); + assertThat(mutations.get(2)).isEqualTo(expectedLeftDelete); + assertThat(mutations.get(3)).isEqualTo(expectedRightDelete); + + verify(connection).close(); + } + + @Test + public void mutate_ForMixOfRegularTableAndVirtualTable_ShouldHandleBothCorrectly() + throws Exception { + // Arrange + VirtualTableInfo virtualTableInfo = createVirtualTableInfo(VirtualTableJoinType.INNER); + when(virtualTableInfoManager.getVirtualTableInfo(NAMESPACE, TABLE)) + .thenReturn(virtualTableInfo); + when(virtualTableInfoManager.getVirtualTableInfo("regular_ns", "regular_table")) + .thenReturn(null); + when(tableMetadataManager.getTableMetadata("left_ns", "left_table")) + .thenReturn(createLeftSourceTableMetadata()); + when(tableMetadataManager.getTableMetadata("right_ns", "right_table")) + .thenReturn(createRightSourceTableMetadata()); + when(jdbcService.mutate(any(), any())).thenReturn(true); + + // Regular table put + Put regularPut = + Put.newBuilder() + .namespace("regular_ns") + .table("regular_table") + .partitionKey(Key.ofText("pk1", "regular_val")) + .clusteringKey(Key.ofText("ck1", "regular_ck")) + .textValue("col1", "value1") + .consistency(Consistency.LINEARIZABLE) + .build(); + + // Virtual table put + Put virtualPut = + Put.newBuilder() + .namespace(NAMESPACE) + .table(TABLE) + .partitionKey(Key.ofText("pk1", "val1")) + .clusteringKey(Key.ofText("ck1", "ck_val1")) + .textValue("left_col1", "left_val1") + .textValue("right_col1", "right_val1") + .consistency(Consistency.LINEARIZABLE) + .build(); + + // Act + jdbcDatabase.mutate(Arrays.asList(regularPut, virtualPut)); + + // Assert + Put expectedRegularPut = + Put.newBuilder() + .namespace("regular_ns") + .table("regular_table") + .partitionKey(Key.ofText("pk1", "regular_val")) + .clusteringKey(Key.ofText("ck1", "regular_ck")) + .textValue("col1", "value1") + .consistency(Consistency.LINEARIZABLE) + .build(); + + Put expectedLeftPut = + Put.newBuilder() + .namespace("left_ns") + .table("left_table") + .partitionKey(Key.ofText("pk1", "val1")) + .clusteringKey(Key.ofText("ck1", "ck_val1")) + .textValue("left_col1", "left_val1") + .consistency(Consistency.LINEARIZABLE) + .build(); + + Put expectedRightPut = + Put.newBuilder() + .namespace("right_ns") + .table("right_table") + .partitionKey(Key.ofText("pk1", "val1")) + .clusteringKey(Key.ofText("ck1", "ck_val1")) + .textValue("right_col1", "right_val1") + .consistency(Consistency.LINEARIZABLE) + .build(); + + @SuppressWarnings("unchecked") + ArgumentCaptor> mutationsCaptor = ArgumentCaptor.forClass(List.class); + verify(jdbcService).mutate(mutationsCaptor.capture(), any()); + + List mutations = mutationsCaptor.getValue(); + // 1 regular Put + 2 virtual Puts (from virtual Put divided) = 3 mutations + assertThat(mutations).hasSize(3); + assertThat(mutations.get(0)).isEqualTo(expectedRegularPut); + assertThat(mutations.get(1)).isEqualTo(expectedLeftPut); + assertThat(mutations.get(2)).isEqualTo(expectedRightPut); + + verify(connection).close(); + } + + private VirtualTableInfo createVirtualTableInfo(VirtualTableJoinType joinType) { + return new VirtualTableInfo() { + @Override + public String getNamespaceName() { + return NAMESPACE; + } + + @Override + public String getTableName() { + return TABLE; + } + + @Override + public String getLeftSourceNamespaceName() { + return "left_ns"; + } + + @Override + public String getLeftSourceTableName() { + return "left_table"; + } + + @Override + public String getRightSourceNamespaceName() { + return "right_ns"; + } + + @Override + public String getRightSourceTableName() { + return "right_table"; + } + + @Override + public VirtualTableJoinType getJoinType() { + return joinType; + } + }; + } + + private TableMetadata createLeftSourceTableMetadata() { + return TableMetadata.newBuilder() + .addColumn("pk1", DataType.TEXT) + .addColumn("ck1", DataType.TEXT) + .addColumn("ck2", DataType.INT) + .addColumn("left_col1", DataType.TEXT) + .addColumn("left_col2", DataType.INT) + .addColumn("left_col3", DataType.BIGINT) + .addPartitionKey("pk1") + .addClusteringKey("ck1") + .addClusteringKey("ck2") + .build(); + } + + private TableMetadata createRightSourceTableMetadata() { + return TableMetadata.newBuilder() + .addColumn("pk1", DataType.TEXT) + .addColumn("ck1", DataType.TEXT) + .addColumn("ck2", DataType.INT) + .addColumn("right_col1", DataType.TEXT) + .addColumn("right_col2", DataType.INT) + .addColumn("right_col3", DataType.DOUBLE) + .addPartitionKey("pk1") + .addClusteringKey("ck1") + .addClusteringKey("ck2") + .build(); + } } diff --git a/core/src/test/java/com/scalar/db/storage/jdbc/VirtualTableMetadataServiceTest.java b/core/src/test/java/com/scalar/db/storage/jdbc/VirtualTableMetadataServiceTest.java new file mode 100644 index 0000000000..64b4a587b1 --- /dev/null +++ b/core/src/test/java/com/scalar/db/storage/jdbc/VirtualTableMetadataServiceTest.java @@ -0,0 +1,473 @@ +package com.scalar.db.storage.jdbc; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.scalar.db.api.VirtualTableInfo; +import com.scalar.db.api.VirtualTableJoinType; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +public class VirtualTableMetadataServiceTest { + + private static final String METADATA_SCHEMA = "scalardb"; + + @Mock private Connection connection; + + private RdbEngineStrategy rdbEngine; + private VirtualTableMetadataService service; + + @BeforeEach + public void setUp() { + MockitoAnnotations.openMocks(this); + rdbEngine = new RdbEngineMysql(); + service = new VirtualTableMetadataService(METADATA_SCHEMA, rdbEngine); + } + + @Test + public void createVirtualTablesTableIfNotExists_TableDoesNotExist_ShouldCreateTable() + throws Exception { + // Arrange + Statement createStatement = mock(Statement.class); + when(connection.createStatement()).thenReturn(createStatement); + + // Act + service.createVirtualTablesTableIfNotExists(connection); + + // Assert + ArgumentCaptor createCaptor = ArgumentCaptor.forClass(String.class); + verify(createStatement).execute(createCaptor.capture()); + assertThat(createCaptor.getValue()) + .isEqualTo( + "CREATE TABLE IF NOT EXISTS `scalardb`.`virtual_tables`(" + + "`full_table_name` VARCHAR(128), " + + "`left_source_table_full_table_name` VARCHAR(128), " + + "`right_source_table_full_table_name` VARCHAR(128), " + + "`join_type` VARCHAR(20), " + + "`attributes` LONGTEXT, " + + "PRIMARY KEY (`full_table_name`))"); + } + + @Test + public void createVirtualTablesTableIfNotExists_TableExists_ShouldSuppressDuplicateTableError() + throws Exception { + // Arrange + Statement createStatement = mock(Statement.class); + SQLException duplicateTableException = new SQLException("Table already exists", "42S01", 1050); + when(createStatement.execute(anyString())).thenThrow(duplicateTableException); + when(connection.createStatement()).thenReturn(createStatement); + + // Act + service.createVirtualTablesTableIfNotExists(connection); + + // Assert + ArgumentCaptor createCaptor = ArgumentCaptor.forClass(String.class); + verify(createStatement).execute(createCaptor.capture()); + assertThat(createCaptor.getValue()) + .isEqualTo( + "CREATE TABLE IF NOT EXISTS `scalardb`.`virtual_tables`(" + + "`full_table_name` VARCHAR(128), " + + "`left_source_table_full_table_name` VARCHAR(128), " + + "`right_source_table_full_table_name` VARCHAR(128), " + + "`join_type` VARCHAR(20), " + + "`attributes` LONGTEXT, " + + "PRIMARY KEY (`full_table_name`))"); + } + + @Test + public void deleteVirtualTablesTableIfEmpty_TableIsEmpty_ShouldDeleteTable() throws Exception { + // Arrange + Statement selectStatement = mock(Statement.class); + Statement dropStatement = mock(Statement.class); + when(connection.createStatement()).thenReturn(selectStatement, dropStatement); + ResultSet resultSet = mock(ResultSet.class); + when(resultSet.next()).thenReturn(false); + when(selectStatement.executeQuery(anyString())).thenReturn(resultSet); + + // Act + service.deleteVirtualTablesTableIfEmpty(connection); + + // Assert + ArgumentCaptor selectCaptor = ArgumentCaptor.forClass(String.class); + verify(selectStatement).executeQuery(selectCaptor.capture()); + assertThat(selectCaptor.getValue()).isEqualTo("SELECT * FROM `scalardb`.`virtual_tables`"); + + ArgumentCaptor dropCaptor = ArgumentCaptor.forClass(String.class); + verify(dropStatement).execute(dropCaptor.capture()); + assertThat(dropCaptor.getValue()).isEqualTo("DROP TABLE `scalardb`.`virtual_tables`"); + } + + @Test + public void deleteVirtualTablesTableIfEmpty_TableIsNotEmpty_ShouldNotDeleteTable() + throws Exception { + // Arrange + Statement selectStatement = mock(Statement.class); + when(connection.createStatement()).thenReturn(selectStatement); + ResultSet resultSet = mock(ResultSet.class); + when(resultSet.next()).thenReturn(true); + when(selectStatement.executeQuery(anyString())).thenReturn(resultSet); + + // Act + service.deleteVirtualTablesTableIfEmpty(connection); + + // Assert + ArgumentCaptor captor = ArgumentCaptor.forClass(String.class); + verify(selectStatement).executeQuery(captor.capture()); + assertThat(captor.getValue()).isEqualTo("SELECT * FROM `scalardb`.`virtual_tables`"); + verify(selectStatement, never()).execute(anyString()); + } + + @Test + public void insertIntoVirtualTablesTable_ShouldInsertVirtualTableMetadata() throws Exception { + // Arrange + PreparedStatement preparedStatement = mock(PreparedStatement.class); + when(connection.prepareStatement(anyString())).thenReturn(preparedStatement); + + String namespace = "ns"; + String table = "vtable"; + String leftSourceNamespace = "ns"; + String leftSourceTable = "left_table"; + String rightSourceNamespace = "ns"; + String rightSourceTable = "right_table"; + VirtualTableJoinType joinType = VirtualTableJoinType.INNER; + String attributes = ""; + + // Act + service.insertIntoVirtualTablesTable( + connection, + namespace, + table, + leftSourceNamespace, + leftSourceTable, + rightSourceNamespace, + rightSourceTable, + joinType, + attributes); + + // Assert + ArgumentCaptor captor = ArgumentCaptor.forClass(String.class); + verify(connection).prepareStatement(captor.capture()); + assertThat(captor.getValue()) + .isEqualTo("INSERT INTO `scalardb`.`virtual_tables` VALUES (?,?,?,?,?)"); + verify(preparedStatement).setString(1, "ns.vtable"); + verify(preparedStatement).setString(2, "ns.left_table"); + verify(preparedStatement).setString(3, "ns.right_table"); + verify(preparedStatement).setString(4, "INNER"); + verify(preparedStatement).setString(5, ""); + verify(preparedStatement).execute(); + } + + @Test + public void deleteFromVirtualTablesTable_ShouldDeleteVirtualTableMetadata() throws Exception { + // Arrange + PreparedStatement preparedStatement = mock(PreparedStatement.class); + when(connection.prepareStatement(anyString())).thenReturn(preparedStatement); + + String namespace = "ns"; + String table = "vtable"; + + // Act + service.deleteFromVirtualTablesTable(connection, namespace, table); + + // Assert + ArgumentCaptor captor = ArgumentCaptor.forClass(String.class); + verify(connection).prepareStatement(captor.capture()); + assertThat(captor.getValue()) + .isEqualTo("DELETE FROM `scalardb`.`virtual_tables` WHERE `full_table_name` = ?"); + verify(preparedStatement).setString(1, "ns.vtable"); + verify(preparedStatement).execute(); + } + + @Test + public void getVirtualTableInfo_VirtualTableExists_ShouldReturnVirtualTableInfo() + throws Exception { + // Arrange + Statement checkStatement = mock(Statement.class); + when(connection.createStatement()).thenReturn(checkStatement); + + PreparedStatement preparedStatement = mock(PreparedStatement.class); + when(connection.prepareStatement(anyString())).thenReturn(preparedStatement); + ResultSet resultSet = mock(ResultSet.class); + when(resultSet.next()).thenReturn(true); + when(resultSet.getString("full_table_name")).thenReturn("ns.vtable"); + when(resultSet.getString("left_source_table_full_table_name")).thenReturn("ns.left_table"); + when(resultSet.getString("right_source_table_full_table_name")).thenReturn("ns.right_table"); + when(resultSet.getString("join_type")).thenReturn("INNER"); + when(preparedStatement.executeQuery()).thenReturn(resultSet); + + String namespace = "ns"; + String table = "vtable"; + + // Act + VirtualTableInfo virtualTableInfo = service.getVirtualTableInfo(connection, namespace, table); + + // Assert + assertThat(virtualTableInfo).isNotNull(); + assertThat(virtualTableInfo.getNamespaceName()).isEqualTo("ns"); + assertThat(virtualTableInfo.getTableName()).isEqualTo("vtable"); + assertThat(virtualTableInfo.getLeftSourceNamespaceName()).isEqualTo("ns"); + assertThat(virtualTableInfo.getLeftSourceTableName()).isEqualTo("left_table"); + assertThat(virtualTableInfo.getRightSourceNamespaceName()).isEqualTo("ns"); + assertThat(virtualTableInfo.getRightSourceTableName()).isEqualTo("right_table"); + assertThat(virtualTableInfo.getJoinType()).isEqualTo(VirtualTableJoinType.INNER); + + ArgumentCaptor checkCaptor = ArgumentCaptor.forClass(String.class); + verify(checkStatement).execute(checkCaptor.capture()); + assertThat(checkCaptor.getValue()) + .isEqualTo("SELECT 1 FROM `scalardb`.`virtual_tables` LIMIT 1"); + + ArgumentCaptor selectCaptor = ArgumentCaptor.forClass(String.class); + verify(connection).prepareStatement(selectCaptor.capture()); + assertThat(selectCaptor.getValue()) + .isEqualTo("SELECT * FROM `scalardb`.`virtual_tables` WHERE `full_table_name` = ?"); + verify(preparedStatement).setString(1, "ns.vtable"); + } + + @Test + public void getVirtualTableInfo_VirtualTableDoesNotExist_ShouldReturnNull() throws Exception { + // Arrange + Statement checkStatement = mock(Statement.class); + when(connection.createStatement()).thenReturn(checkStatement); + + PreparedStatement preparedStatement = mock(PreparedStatement.class); + when(connection.prepareStatement(anyString())).thenReturn(preparedStatement); + ResultSet resultSet = mock(ResultSet.class); + when(resultSet.next()).thenReturn(false); + when(preparedStatement.executeQuery()).thenReturn(resultSet); + + String namespace = "ns"; + String table = "vtable"; + + // Act + VirtualTableInfo virtualTableInfo = service.getVirtualTableInfo(connection, namespace, table); + + // Assert + assertThat(virtualTableInfo).isNull(); + + ArgumentCaptor checkCaptor = ArgumentCaptor.forClass(String.class); + verify(checkStatement).execute(checkCaptor.capture()); + assertThat(checkCaptor.getValue()) + .isEqualTo("SELECT 1 FROM `scalardb`.`virtual_tables` LIMIT 1"); + + ArgumentCaptor selectCaptor = ArgumentCaptor.forClass(String.class); + verify(connection).prepareStatement(selectCaptor.capture()); + assertThat(selectCaptor.getValue()) + .isEqualTo("SELECT * FROM `scalardb`.`virtual_tables` WHERE `full_table_name` = ?"); + verify(preparedStatement).setString(1, "ns.vtable"); + } + + @Test + public void getVirtualTableInfo_MetadataTableDoesNotExist_ShouldReturnNull() throws Exception { + // Arrange + Statement statement = mock(Statement.class); + when(connection.createStatement()).thenReturn(statement); + SQLException sqlException = new SQLException("Table doesn't exist", "42S02", 1146); + when(statement.execute(anyString())).thenThrow(sqlException); + + String namespace = "ns"; + String table = "vtable"; + + // Act + VirtualTableInfo virtualTableInfo = service.getVirtualTableInfo(connection, namespace, table); + + // Assert + assertThat(virtualTableInfo).isNull(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(String.class); + verify(statement).execute(captor.capture()); + assertThat(captor.getValue()).isEqualTo("SELECT 1 FROM `scalardb`.`virtual_tables` LIMIT 1"); + } + + @Test + public void getVirtualTableInfosBySourceTable_ShouldReturnVirtualTableInfos() throws Exception { + // Arrange + Statement checkStatement = mock(Statement.class); + when(connection.createStatement()).thenReturn(checkStatement); + + PreparedStatement preparedStatement = mock(PreparedStatement.class); + when(connection.prepareStatement(anyString())).thenReturn(preparedStatement); + ResultSet resultSet = mock(ResultSet.class); + when(resultSet.next()).thenReturn(true, true, false); + when(resultSet.getString("full_table_name")).thenReturn("ns.vtable1", "ns.vtable2"); + when(resultSet.getString("left_source_table_full_table_name")) + .thenReturn("ns.source_table", "ns.left_table"); + when(resultSet.getString("right_source_table_full_table_name")) + .thenReturn("ns.right_table", "ns.source_table"); + when(resultSet.getString("join_type")).thenReturn("INNER", "LEFT_OUTER"); + when(preparedStatement.executeQuery()).thenReturn(resultSet); + + String sourceNamespace = "ns"; + String sourceTable = "source_table"; + + // Act + List virtualTableInfos = + service.getVirtualTableInfosBySourceTable(connection, sourceNamespace, sourceTable); + + // Assert + assertThat(virtualTableInfos).hasSize(2); + assertThat(virtualTableInfos.get(0).getNamespaceName()).isEqualTo("ns"); + assertThat(virtualTableInfos.get(0).getTableName()).isEqualTo("vtable1"); + assertThat(virtualTableInfos.get(0).getLeftSourceTableName()).isEqualTo("source_table"); + assertThat(virtualTableInfos.get(0).getJoinType()).isEqualTo(VirtualTableJoinType.INNER); + assertThat(virtualTableInfos.get(1).getTableName()).isEqualTo("vtable2"); + assertThat(virtualTableInfos.get(1).getRightSourceTableName()).isEqualTo("source_table"); + assertThat(virtualTableInfos.get(1).getJoinType()).isEqualTo(VirtualTableJoinType.LEFT_OUTER); + + ArgumentCaptor checkCaptor = ArgumentCaptor.forClass(String.class); + verify(checkStatement).execute(checkCaptor.capture()); + assertThat(checkCaptor.getValue()) + .isEqualTo("SELECT 1 FROM `scalardb`.`virtual_tables` LIMIT 1"); + + ArgumentCaptor selectCaptor = ArgumentCaptor.forClass(String.class); + verify(connection).prepareStatement(selectCaptor.capture()); + assertThat(selectCaptor.getValue()) + .isEqualTo( + "SELECT * FROM `scalardb`.`virtual_tables` WHERE `left_source_table_full_table_name` = ? OR `right_source_table_full_table_name` = ?"); + verify(preparedStatement).setString(1, "ns.source_table"); + verify(preparedStatement).setString(2, "ns.source_table"); + } + + @Test + public void getVirtualTableInfosBySourceTable_MetadataTableDoesNotExist_ShouldReturnEmptyList() + throws Exception { + // Arrange + Statement checkStatement = mock(Statement.class); + SQLException sqlException = new SQLException("Table doesn't exist", "42S02", 1146); + when(checkStatement.execute(anyString())).thenThrow(sqlException); + when(connection.createStatement()).thenReturn(checkStatement); + + String sourceNamespace = "ns"; + String sourceTable = "source_table"; + + // Act + List virtualTableInfos = + service.getVirtualTableInfosBySourceTable(connection, sourceNamespace, sourceTable); + + // Assert + assertThat(virtualTableInfos).isEmpty(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(String.class); + verify(checkStatement).execute(captor.capture()); + assertThat(captor.getValue()).isEqualTo("SELECT 1 FROM `scalardb`.`virtual_tables` LIMIT 1"); + } + + @Test + public void getNamespaceTableNames_ShouldReturnTableNames() throws Exception { + // Arrange + Statement checkStatement = mock(Statement.class); + when(connection.createStatement()).thenReturn(checkStatement); + + PreparedStatement preparedStatement = mock(PreparedStatement.class); + when(connection.prepareStatement(anyString())).thenReturn(preparedStatement); + ResultSet resultSet = mock(ResultSet.class); + when(resultSet.next()).thenReturn(true, true, false); + when(resultSet.getString("full_table_name")).thenReturn("ns.table1", "ns.table2"); + when(preparedStatement.executeQuery()).thenReturn(resultSet); + + String namespace = "ns"; + + // Act + Set tableNames = service.getNamespaceTableNames(connection, namespace); + + // Assert + assertThat(tableNames).containsExactlyInAnyOrder("table1", "table2"); + + ArgumentCaptor checkCaptor = ArgumentCaptor.forClass(String.class); + verify(checkStatement).execute(checkCaptor.capture()); + assertThat(checkCaptor.getValue()) + .isEqualTo("SELECT 1 FROM `scalardb`.`virtual_tables` LIMIT 1"); + + ArgumentCaptor selectCaptor = ArgumentCaptor.forClass(String.class); + verify(connection).prepareStatement(selectCaptor.capture()); + assertThat(selectCaptor.getValue()) + .isEqualTo( + "SELECT `full_table_name` FROM `scalardb`.`virtual_tables` WHERE `full_table_name` LIKE ?"); + verify(preparedStatement).setString(1, "ns.%"); + } + + @Test + public void getNamespaceTableNames_MetadataTableDoesNotExist_ShouldReturnEmptySet() + throws Exception { + // Arrange + Statement checkStatement = mock(Statement.class); + SQLException sqlException = new SQLException("Table doesn't exist", "42S02", 1146); + when(checkStatement.execute(anyString())).thenThrow(sqlException); + when(connection.createStatement()).thenReturn(checkStatement); + + String namespace = "ns"; + + // Act + Set tableNames = service.getNamespaceTableNames(connection, namespace); + + // Assert + assertThat(tableNames).isEmpty(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(String.class); + verify(checkStatement).execute(captor.capture()); + assertThat(captor.getValue()).isEqualTo("SELECT 1 FROM `scalardb`.`virtual_tables` LIMIT 1"); + } + + @Test + public void getNamespaceNamesOfExistingTables_ShouldReturnNamespaceNames() throws Exception { + // Arrange + Statement checkStatement = mock(Statement.class); + Statement selectStatement = mock(Statement.class); + when(connection.createStatement()).thenReturn(checkStatement, selectStatement); + + ResultSet resultSet = mock(ResultSet.class); + when(resultSet.next()).thenReturn(true, true, true, false); + when(resultSet.getString("full_table_name")) + .thenReturn("ns1.table1", "ns1.table2", "ns2.table1"); + when(selectStatement.executeQuery(anyString())).thenReturn(resultSet); + + // Act + Set namespaceNames = service.getNamespaceNamesOfExistingTables(connection); + + // Assert + assertThat(namespaceNames).containsExactlyInAnyOrder("ns1", "ns2"); + + ArgumentCaptor checkCaptor = ArgumentCaptor.forClass(String.class); + verify(checkStatement).execute(checkCaptor.capture()); + assertThat(checkCaptor.getValue()) + .isEqualTo("SELECT 1 FROM `scalardb`.`virtual_tables` LIMIT 1"); + + ArgumentCaptor selectCaptor = ArgumentCaptor.forClass(String.class); + verify(selectStatement).executeQuery(selectCaptor.capture()); + assertThat(selectCaptor.getValue()) + .isEqualTo("SELECT `full_table_name` FROM `scalardb`.`virtual_tables`"); + } + + @Test + public void getNamespaceNamesOfExistingTables_MetadataTableDoesNotExist_ShouldReturnEmptySet() + throws Exception { + // Arrange + Statement checkStatement = mock(Statement.class); + SQLException sqlException = new SQLException("Table doesn't exist", "42S02", 1146); + when(checkStatement.execute(anyString())).thenThrow(sqlException); + when(connection.createStatement()).thenReturn(checkStatement); + + // Act + Set namespaceNames = service.getNamespaceNamesOfExistingTables(connection); + + // Assert + assertThat(namespaceNames).isEmpty(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(String.class); + verify(checkStatement).execute(captor.capture()); + assertThat(captor.getValue()).isEqualTo("SELECT 1 FROM `scalardb`.`virtual_tables` LIMIT 1"); + } +} diff --git a/core/src/test/java/com/scalar/db/storage/multistorage/MultiStorageAdminTest.java b/core/src/test/java/com/scalar/db/storage/multistorage/MultiStorageAdminTest.java index b57c19119b..7041f08ef7 100644 --- a/core/src/test/java/com/scalar/db/storage/multistorage/MultiStorageAdminTest.java +++ b/core/src/test/java/com/scalar/db/storage/multistorage/MultiStorageAdminTest.java @@ -1,7 +1,9 @@ package com.scalar.db.storage.multistorage; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -10,12 +12,15 @@ import com.scalar.db.api.DistributedStorageAdmin; import com.scalar.db.api.StorageInfo; import com.scalar.db.api.TableMetadata; +import com.scalar.db.api.VirtualTableInfo; +import com.scalar.db.api.VirtualTableJoinType; import com.scalar.db.common.StorageInfoImpl; import com.scalar.db.exception.storage.ExecutionException; import com.scalar.db.io.DataType; import java.util.Collections; import java.util.HashMap; import java.util.Map; +import java.util.Optional; import java.util.Set; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -885,4 +890,145 @@ public void getStorageInfo_ShouldReturnProperStorageInfo() throws ExecutionExcep verify(admin2).getStorageInfo("ns2"); verify(admin3).getStorageInfo("ns3"); } + + @Test + public void createVirtualTable_ProperArgumentsGiven_ShouldCallAdminProperly() + throws ExecutionException { + // Arrange + String namespace = NAMESPACE2; + String table = "vtable"; + String leftSourceNamespace = NAMESPACE2; + String leftSourceTable = "left_table"; + String rightSourceNamespace = NAMESPACE2; + String rightSourceTable = "right_table"; + VirtualTableJoinType joinType = VirtualTableJoinType.INNER; + Map options = Collections.emptyMap(); + + // Mock getStorageInfo to return the same storage for all namespaces + StorageInfo storageInfo = + new StorageInfoImpl("s2", StorageInfo.MutationAtomicityUnit.NAMESPACE, Integer.MAX_VALUE); + when(admin2.getStorageInfo(namespace)).thenReturn(storageInfo); + when(admin2.getStorageInfo(leftSourceNamespace)).thenReturn(storageInfo); + when(admin2.getStorageInfo(rightSourceNamespace)).thenReturn(storageInfo); + + // Act + multiStorageAdmin.createVirtualTable( + namespace, + table, + leftSourceNamespace, + leftSourceTable, + rightSourceNamespace, + rightSourceTable, + joinType, + options); + + // Assert + verify(admin2) + .createVirtualTable( + namespace, + table, + leftSourceNamespace, + leftSourceTable, + rightSourceNamespace, + rightSourceTable, + joinType, + options); + } + + @Test + public void + createVirtualTable_VirtualTableInDifferentStorageFromSourceTables_ShouldThrowIllegalArgumentException() + throws ExecutionException { + // Arrange + String namespace = NAMESPACE3; + String table = "vtable"; + String leftSourceNamespace = NAMESPACE2; + String leftSourceTable = "left_table"; + String rightSourceNamespace = NAMESPACE2; + String rightSourceTable = "right_table"; + VirtualTableJoinType joinType = VirtualTableJoinType.INNER; + Map options = Collections.emptyMap(); + + // Mock getStorageInfo - virtual table in s3, both sources in s2 + StorageInfo storageInfoForNamespace = + new StorageInfoImpl("s3", StorageInfo.MutationAtomicityUnit.NAMESPACE, Integer.MAX_VALUE); + StorageInfo storageInfoForSources = + new StorageInfoImpl("s2", StorageInfo.MutationAtomicityUnit.NAMESPACE, Integer.MAX_VALUE); + when(admin3.getStorageInfo(namespace)).thenReturn(storageInfoForNamespace); + when(admin2.getStorageInfo(leftSourceNamespace)).thenReturn(storageInfoForSources); + when(admin2.getStorageInfo(rightSourceNamespace)).thenReturn(storageInfoForSources); + + // Act Assert + assertThatThrownBy( + () -> + multiStorageAdmin.createVirtualTable( + namespace, + table, + leftSourceNamespace, + leftSourceTable, + rightSourceNamespace, + rightSourceTable, + joinType, + options)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("virtual table must be in the same storage as its source tables"); + } + + @Test + public void + createVirtualTable_SourceTablesInDifferentStorages_ShouldThrowIllegalArgumentException() + throws ExecutionException { + // Arrange + String namespace = NAMESPACE2; + String table = "vtable"; + String leftSourceNamespace = NAMESPACE2; + String leftSourceTable = "left_table"; + String rightSourceNamespace = NAMESPACE3; + String rightSourceTable = "right_table"; + VirtualTableJoinType joinType = VirtualTableJoinType.INNER; + Map options = Collections.emptyMap(); + + // Mock getStorageInfo to return different storages for left and right sources + StorageInfo storageInfo1 = + new StorageInfoImpl("s2", StorageInfo.MutationAtomicityUnit.NAMESPACE, Integer.MAX_VALUE); + StorageInfo storageInfo2 = + new StorageInfoImpl("s3", StorageInfo.MutationAtomicityUnit.NAMESPACE, Integer.MAX_VALUE); + when(admin2.getStorageInfo(namespace)).thenReturn(storageInfo1); + when(admin2.getStorageInfo(leftSourceNamespace)).thenReturn(storageInfo1); + when(admin3.getStorageInfo(rightSourceNamespace)).thenReturn(storageInfo2); + + // Act Assert + assertThatThrownBy( + () -> + multiStorageAdmin.createVirtualTable( + namespace, + table, + leftSourceNamespace, + leftSourceTable, + rightSourceNamespace, + rightSourceTable, + joinType, + options)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("source tables must be in the same storage"); + } + + @Test + public void getVirtualTableInfo_ProperArgumentsGiven_ShouldCallAdminProperly() + throws ExecutionException { + // Arrange + String namespace = NAMESPACE1; + String table = TABLE1; + + VirtualTableInfo virtualTableInfo = mock(VirtualTableInfo.class); + when(admin1.getVirtualTableInfo(namespace, table)).thenReturn(Optional.of(virtualTableInfo)); + + // Act + Optional result = multiStorageAdmin.getVirtualTableInfo(namespace, table); + + // Assert + assertThat(result).isPresent(); + assertThat(result.get()).isEqualTo(virtualTableInfo); + verify(admin1).getVirtualTableInfo(namespace, table); + } } diff --git a/integration-test/src/main/java/com/scalar/db/api/DistributedStorageVirtualTablesIntegrationTestBase.java b/integration-test/src/main/java/com/scalar/db/api/DistributedStorageVirtualTablesIntegrationTestBase.java new file mode 100644 index 0000000000..cb8d07ddd1 --- /dev/null +++ b/integration-test/src/main/java/com/scalar/db/api/DistributedStorageVirtualTablesIntegrationTestBase.java @@ -0,0 +1,1435 @@ +package com.scalar.db.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.scalar.db.exception.storage.NoMutationException; +import com.scalar.db.io.DataType; +import com.scalar.db.io.Key; +import com.scalar.db.service.StorageFactory; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Properties; +import java.util.Set; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +public abstract class DistributedStorageVirtualTablesIntegrationTestBase { + private static final Logger logger = + LoggerFactory.getLogger(DistributedStorageVirtualTablesIntegrationTestBase.class); + + private static final String TEST_NAME = "storage_vtable"; + private static final String NAMESPACE = "int_test_" + TEST_NAME; + private static final String LEFT_SOURCE_TABLE = "left_source_table"; + private static final String RIGHT_SOURCE_TABLE = "right_source_table"; + private static final String VIRTUAL_TABLE = "virtual_table"; + + private DistributedStorageAdmin admin; + private DistributedStorage storage; + private String namespace; + + @BeforeAll + public void beforeAll() throws Exception { + initialize(TEST_NAME); + StorageFactory factory = StorageFactory.create(getProperties(TEST_NAME)); + admin = factory.getStorageAdmin(); + namespace = getNamespace(); + storage = factory.getStorage(); + admin.createNamespace(namespace, true, getCreationOptions()); + } + + protected void initialize(String testName) throws Exception {} + + protected abstract Properties getProperties(String testName); + + protected String getNamespace() { + return NAMESPACE; + } + + protected Map getCreationOptions() { + return Collections.emptyMap(); + } + + @BeforeEach + public void setUp() throws Exception { + // Create left source table with partition key (pk1, pk2), clustering key (ck1) and columns + // (col1, col2, col3, col4, col5) + TableMetadata leftSourceTableMetadata = + TableMetadata.newBuilder() + .addPartitionKey("pk1") + .addPartitionKey("pk2") + .addClusteringKey("ck1") + .addColumn("pk1", DataType.INT) + .addColumn("pk2", DataType.TEXT) + .addColumn("ck1", DataType.TEXT) + .addColumn("col1", DataType.INT) + .addColumn("col2", DataType.TEXT) + .addColumn("col3", DataType.BIGINT) + .addColumn("col4", DataType.FLOAT) + .addColumn("col5", DataType.BOOLEAN) + .build(); + admin.createTable( + namespace, LEFT_SOURCE_TABLE, leftSourceTableMetadata, true, getCreationOptions()); + + // Create right source table with the same partition key (pk1, pk2), clustering key (ck1) and + // different columns (col6, col7, col8, col9, col10) + TableMetadata rightSourceTableMetadata = + TableMetadata.newBuilder() + .addPartitionKey("pk1") + .addPartitionKey("pk2") + .addClusteringKey("ck1") + .addColumn("pk1", DataType.INT) + .addColumn("pk2", DataType.TEXT) + .addColumn("ck1", DataType.TEXT) + .addColumn("col6", DataType.DOUBLE) + .addColumn("col7", DataType.TEXT) + .addColumn("col8", DataType.INT) + .addColumn("col9", DataType.BOOLEAN) + .addColumn("col10", DataType.BIGINT) + .build(); + admin.createTable( + namespace, RIGHT_SOURCE_TABLE, rightSourceTableMetadata, true, getCreationOptions()); + + // Create virtual table + admin.createVirtualTable( + namespace, + VIRTUAL_TABLE, + namespace, + LEFT_SOURCE_TABLE, + namespace, + RIGHT_SOURCE_TABLE, + VirtualTableJoinType.INNER, + true, + getCreationOptions()); + } + + @AfterEach + public void tearDown() throws Exception { + try { + admin.dropTable(namespace, VIRTUAL_TABLE, true); + } catch (Exception e) { + logger.warn("Failed to drop virtual table", e); + } + try { + admin.dropTable(namespace, RIGHT_SOURCE_TABLE, true); + } catch (Exception e) { + logger.warn("Failed to drop right source table", e); + } + try { + admin.dropTable(namespace, LEFT_SOURCE_TABLE, true); + } catch (Exception e) { + logger.warn("Failed to drop left source table", e); + } + } + + @AfterAll + public void afterAll() throws Exception { + if (admin != null) { + try { + admin.dropNamespace(namespace, true); + } catch (Exception e) { + logger.warn("Failed to drop namespace", e); + } + try { + admin.close(); + } catch (Exception e) { + logger.warn("Failed to close admin", e); + } + } + + try { + if (storage != null) { + storage.close(); + } + } catch (Exception e) { + logger.warn("Failed to close storage", e); + } + } + + @Test + public void getTableMetadata_ForVirtualTable_ShouldReturnCorrectMetadata() throws Exception { + // Arrange + TableMetadata expectedMetadata = + TableMetadata.newBuilder() + .addPartitionKey("pk1") + .addPartitionKey("pk2") + .addClusteringKey("ck1") + .addColumn("pk1", DataType.INT) + .addColumn("pk2", DataType.TEXT) + .addColumn("ck1", DataType.TEXT) + .addColumn("col1", DataType.INT) + .addColumn("col2", DataType.TEXT) + .addColumn("col3", DataType.BIGINT) + .addColumn("col4", DataType.FLOAT) + .addColumn("col5", DataType.BOOLEAN) + .addColumn("col6", DataType.DOUBLE) + .addColumn("col7", DataType.TEXT) + .addColumn("col8", DataType.INT) + .addColumn("col9", DataType.BOOLEAN) + .addColumn("col10", DataType.BIGINT) + .build(); + + // Act + TableMetadata virtualTableMetadata = admin.getTableMetadata(namespace, VIRTUAL_TABLE); + + // Assert + assertThat(virtualTableMetadata).isEqualTo(expectedMetadata); + } + + @Test + public void getVirtualTableInfo_ForVirtualTable_ShouldReturnCorrectInfo() throws Exception { + // Arrange + + // Act + Optional tableInfo = admin.getVirtualTableInfo(namespace, VIRTUAL_TABLE); + + // Assert + assertThat(tableInfo).isPresent(); + assertThat(tableInfo.get().getLeftSourceNamespaceName()).isEqualTo(namespace); + assertThat(tableInfo.get().getLeftSourceTableName()).isEqualTo(LEFT_SOURCE_TABLE); + assertThat(tableInfo.get().getRightSourceNamespaceName()).isEqualTo(namespace); + assertThat(tableInfo.get().getRightSourceTableName()).isEqualTo(RIGHT_SOURCE_TABLE); + assertThat(tableInfo.get().getJoinType()).isEqualTo(VirtualTableJoinType.INNER); + } + + @Test + public void getNamespaceTableNames_ShouldIncludeVirtualTable() throws Exception { + // Arrange - create additional regular tables + String regularTable1 = "regular_table1"; + String regularTable2 = "regular_table2"; + TableMetadata regularTableMetadata = + TableMetadata.newBuilder() + .addPartitionKey("pk") + .addColumn("pk", DataType.INT) + .addColumn("col", DataType.TEXT) + .build(); + + try { + admin.createTable(namespace, regularTable1, regularTableMetadata, true, getCreationOptions()); + admin.createTable(namespace, regularTable2, regularTableMetadata, true, getCreationOptions()); + + // Act + Set tableNames = admin.getNamespaceTableNames(namespace); + + // Assert - should include source tables, virtual table, and regular tables + assertThat(tableNames) + .containsExactlyInAnyOrder( + LEFT_SOURCE_TABLE, RIGHT_SOURCE_TABLE, VIRTUAL_TABLE, regularTable1, regularTable2); + } finally { + // Clean up + try { + admin.dropTable(namespace, regularTable1, true); + } catch (Exception e) { + logger.warn("Failed to drop regular_table1", e); + } + try { + admin.dropTable(namespace, regularTable2, true); + } catch (Exception e) { + logger.warn("Failed to drop regular_table2", e); + } + } + } + + @Test + public void truncateTable_ForVirtualTable_ShouldTruncateSourceTables() throws Exception { + // Arrange - insert data into both source tables via virtual table + storage.put( + Put.newBuilder() + .namespace(namespace) + .table(VIRTUAL_TABLE) + .partitionKey(Key.of("pk1", 1, "pk2", "1")) + .clusteringKey(Key.ofText("ck1", "aaa")) + .intValue("col1", 100) + .textValue("col2", "data1") + .bigIntValue("col3", 1000L) + .floatValue("col4", 10.5f) + .booleanValue("col5", true) + .doubleValue("col6", 20.5) + .textValue("col7", "data2") + .intValue("col8", 200) + .booleanValue("col9", false) + .bigIntValue("col10", 2000L) + .build()); + storage.put( + Put.newBuilder() + .namespace(namespace) + .table(VIRTUAL_TABLE) + .partitionKey(Key.of("pk1", 2, "pk2", "2")) + .clusteringKey(Key.ofText("ck1", "bbb")) + .intValue("col1", 101) + .textValue("col2", "data3") + .bigIntValue("col3", 1001L) + .floatValue("col4", 11.5f) + .booleanValue("col5", false) + .doubleValue("col6", 21.5) + .textValue("col7", "data4") + .intValue("col8", 201) + .booleanValue("col9", true) + .bigIntValue("col10", 2001L) + .build()); + + // Verify data exists + Scanner scanner = + storage.scan( + Scan.newBuilder() + .namespace(namespace) + .table(VIRTUAL_TABLE) + .partitionKey(Key.of("pk1", 1, "pk2", "1")) + .build()); + assertThat(scanner.all()).isNotEmpty(); + scanner.close(); + + // Act + admin.truncateTable(namespace, VIRTUAL_TABLE); + + // Assert - verify both source tables are empty + Scanner leftScanner = + storage.scan( + Scan.newBuilder() + .namespace(namespace) + .table(LEFT_SOURCE_TABLE) + .partitionKey(Key.of("pk1", 1, "pk2", "1")) + .build()); + assertThat(leftScanner.all()).isEmpty(); + leftScanner.close(); + + Scanner rightScanner = + storage.scan( + Scan.newBuilder() + .namespace(namespace) + .table(RIGHT_SOURCE_TABLE) + .partitionKey(Key.of("pk1", 1, "pk2", "1")) + .build()); + assertThat(rightScanner.all()).isEmpty(); + rightScanner.close(); + + // Verify via virtual table + Scanner virtualScanner = + storage.scan( + Scan.newBuilder() + .namespace(namespace) + .table(VIRTUAL_TABLE) + .partitionKey(Key.of("pk1", 1, "pk2", "1")) + .build()); + assertThat(virtualScanner.all()).isEmpty(); + virtualScanner.close(); + } + + @Test + public void dropTable_ForVirtualTable_ShouldDropVirtualTableSuccessfully() throws Exception { + // Arrange - verify virtual table exists + assertThat(admin.tableExists(namespace, VIRTUAL_TABLE)).isTrue(); + Optional virtualTableInfoBefore = + admin.getVirtualTableInfo(namespace, VIRTUAL_TABLE); + assertThat(virtualTableInfoBefore).isPresent(); + + // Act + admin.dropTable(namespace, VIRTUAL_TABLE); + + // Assert - verify virtual table no longer exists + assertThat(admin.tableExists(namespace, VIRTUAL_TABLE)).isFalse(); + + // Verify source tables still exist + assertThat(admin.tableExists(namespace, LEFT_SOURCE_TABLE)).isTrue(); + assertThat(admin.tableExists(namespace, RIGHT_SOURCE_TABLE)).isTrue(); + } + + @Test + public void + dropTable_ForSourceTable_WhenReferencedByVirtualTable_ShouldThrowIllegalArgumentException() + throws Exception { + // Arrange - verify virtual table and source tables exist + assertThat(admin.tableExists(namespace, VIRTUAL_TABLE)).isTrue(); + assertThat(admin.tableExists(namespace, LEFT_SOURCE_TABLE)).isTrue(); + assertThat(admin.tableExists(namespace, RIGHT_SOURCE_TABLE)).isTrue(); + + // Act Assert - try to drop left source table + assertThatThrownBy(() -> admin.dropTable(namespace, LEFT_SOURCE_TABLE)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining(LEFT_SOURCE_TABLE) + .hasMessageContaining(VIRTUAL_TABLE); + + // Act Assert - try to drop right source table + assertThatThrownBy(() -> admin.dropTable(namespace, RIGHT_SOURCE_TABLE)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining(RIGHT_SOURCE_TABLE) + .hasMessageContaining(VIRTUAL_TABLE); + + // Assert - verify all tables still exist + assertThat(admin.tableExists(namespace, LEFT_SOURCE_TABLE)).isTrue(); + assertThat(admin.tableExists(namespace, RIGHT_SOURCE_TABLE)).isTrue(); + assertThat(admin.tableExists(namespace, VIRTUAL_TABLE)).isTrue(); + } + + @Test + public void createIndex_OnLeftSourceTableColumn_ShouldCreateIndexSuccessfully() throws Exception { + // Arrange + String columnName = "col1"; // col1 belongs to left source table + + TableMetadata leftSourceTableMetadataBefore = + admin.getTableMetadata(namespace, LEFT_SOURCE_TABLE); + assertThat(leftSourceTableMetadataBefore).isNotNull(); + assertThat(leftSourceTableMetadataBefore.getSecondaryIndexNames()).doesNotContain(columnName); + + TableMetadata virtualTableMetadataBefore = admin.getTableMetadata(namespace, VIRTUAL_TABLE); + assertThat(virtualTableMetadataBefore).isNotNull(); + assertThat(virtualTableMetadataBefore.getSecondaryIndexNames()).doesNotContain(columnName); + + // Act + admin.createIndex(namespace, VIRTUAL_TABLE, columnName, true); + + // Assert + TableMetadata leftSourceTableMetadataAfter = + admin.getTableMetadata(namespace, LEFT_SOURCE_TABLE); + assertThat(leftSourceTableMetadataAfter).isNotNull(); + assertThat(leftSourceTableMetadataAfter.getSecondaryIndexNames()).contains(columnName); + + TableMetadata virtualTableMetadataAfter = admin.getTableMetadata(namespace, VIRTUAL_TABLE); + assertThat(virtualTableMetadataAfter).isNotNull(); + assertThat(virtualTableMetadataAfter.getSecondaryIndexNames()).contains(columnName); + } + + @Test + public void createIndex_OnRightSourceTableColumn_ShouldCreateIndexSuccessfully() + throws Exception { + // Arrange + String columnName = "col6"; // col6 belongs to right source table + + TableMetadata rightSourceTableMetadataBefore = + admin.getTableMetadata(namespace, RIGHT_SOURCE_TABLE); + assertThat(rightSourceTableMetadataBefore).isNotNull(); + assertThat(rightSourceTableMetadataBefore.getSecondaryIndexNames()).doesNotContain(columnName); + + TableMetadata virtualTableMetadataBefore = admin.getTableMetadata(namespace, VIRTUAL_TABLE); + assertThat(virtualTableMetadataBefore).isNotNull(); + assertThat(virtualTableMetadataBefore.getSecondaryIndexNames()).doesNotContain(columnName); + + // Act + admin.createIndex(namespace, VIRTUAL_TABLE, columnName, true); + + // Assert + TableMetadata rightSourceTableMetadataAfter = + admin.getTableMetadata(namespace, RIGHT_SOURCE_TABLE); + assertThat(rightSourceTableMetadataAfter).isNotNull(); + assertThat(rightSourceTableMetadataAfter.getSecondaryIndexNames()).contains(columnName); + + TableMetadata virtualTableMetadataAfter = admin.getTableMetadata(namespace, VIRTUAL_TABLE); + assertThat(virtualTableMetadataAfter).isNotNull(); + assertThat(virtualTableMetadataAfter.getSecondaryIndexNames()).contains(columnName); + } + + @Test + public void dropIndex_OnLeftSourceTableColumn_ShouldDropIndexSuccessfully() throws Exception { + // Arrange + String columnName = "col1"; // col1 belongs to left source table + admin.createIndex(namespace, VIRTUAL_TABLE, columnName, true); + + TableMetadata leftSourceTableMetadataBefore = + admin.getTableMetadata(namespace, LEFT_SOURCE_TABLE); + assertThat(leftSourceTableMetadataBefore).isNotNull(); + assertThat(leftSourceTableMetadataBefore.getSecondaryIndexNames()).contains(columnName); + + TableMetadata virtualTableMetadataBefore = admin.getTableMetadata(namespace, VIRTUAL_TABLE); + assertThat(virtualTableMetadataBefore).isNotNull(); + assertThat(virtualTableMetadataBefore.getSecondaryIndexNames()).contains(columnName); + + // Act + admin.dropIndex(namespace, VIRTUAL_TABLE, columnName); + + // Assert + TableMetadata leftSourceTableMetadataAfter = + admin.getTableMetadata(namespace, LEFT_SOURCE_TABLE); + assertThat(leftSourceTableMetadataAfter).isNotNull(); + assertThat(leftSourceTableMetadataAfter.getSecondaryIndexNames()).doesNotContain(columnName); + + TableMetadata virtualTableMetadataAfter = admin.getTableMetadata(namespace, VIRTUAL_TABLE); + assertThat(virtualTableMetadataAfter).isNotNull(); + assertThat(virtualTableMetadataAfter.getSecondaryIndexNames()).doesNotContain(columnName); + } + + @Test + public void dropIndex_OnRightSourceTableColumn_ShouldDropIndexSuccessfully() throws Exception { + // Arrange + String columnName = "col6"; // col6 belongs to right source table + admin.createIndex(namespace, VIRTUAL_TABLE, columnName, true); + + TableMetadata rightSourceTableMetadataBefore = + admin.getTableMetadata(namespace, RIGHT_SOURCE_TABLE); + assertThat(rightSourceTableMetadataBefore).isNotNull(); + assertThat(rightSourceTableMetadataBefore.getSecondaryIndexNames()).contains(columnName); + + TableMetadata virtualTableMetadataBefore = admin.getTableMetadata(namespace, VIRTUAL_TABLE); + assertThat(virtualTableMetadataBefore).isNotNull(); + assertThat(virtualTableMetadataBefore.getSecondaryIndexNames()).contains(columnName); + + // Act + admin.dropIndex(namespace, VIRTUAL_TABLE, columnName); + + // Assert + TableMetadata rightSourceTableMetadataAfter = + admin.getTableMetadata(namespace, RIGHT_SOURCE_TABLE); + assertThat(rightSourceTableMetadataAfter).isNotNull(); + assertThat(rightSourceTableMetadataAfter.getSecondaryIndexNames()).doesNotContain(columnName); + + TableMetadata virtualTableMetadataAfter = admin.getTableMetadata(namespace, VIRTUAL_TABLE); + assertThat(virtualTableMetadataAfter).isNotNull(); + assertThat(virtualTableMetadataAfter.getSecondaryIndexNames()).doesNotContain(columnName); + } + + @Test + public void get_ForVirtualTable_ShouldGetProperly() throws Exception { + // Arrange + storage.put( + Put.newBuilder() + .namespace(namespace) + .table(VIRTUAL_TABLE) + .partitionKey(Key.of("pk1", 1, "pk2", "1")) + .clusteringKey(Key.ofText("ck1", "aaa")) + .intValue("col1", 100) + .textValue("col2", "data1") + .bigIntValue("col3", 1000L) + .floatValue("col4", 10.5f) + .booleanValue("col5", true) + .doubleValue("col6", 20.5) + .textValue("col7", "data2") + .intValue("col8", 200) + .booleanValue("col9", false) + .bigIntValue("col10", 2000L) + .build()); + + // Act + Optional result = + storage.get( + Get.newBuilder() + .namespace(namespace) + .table(VIRTUAL_TABLE) + .partitionKey(Key.of("pk1", 1, "pk2", "1")) + .clusteringKey(Key.ofText("ck1", "aaa")) + .build()); + + // Assert + assertThat(result).isPresent(); + assertThat(result.get().getInt("pk1")).isEqualTo(1); + assertThat(result.get().getText("pk2")).isEqualTo("1"); + assertThat(result.get().getText("ck1")).isEqualTo("aaa"); + assertThat(result.get().getInt("col1")).isEqualTo(100); + assertThat(result.get().getText("col2")).isEqualTo("data1"); + assertThat(result.get().getBigInt("col3")).isEqualTo(1000L); + assertThat(result.get().getFloat("col4")).isEqualTo(10.5f); + assertThat(result.get().getBoolean("col5")).isEqualTo(true); + assertThat(result.get().getDouble("col6")).isEqualTo(20.5); + assertThat(result.get().getText("col7")).isEqualTo("data2"); + assertThat(result.get().getInt("col8")).isEqualTo(200); + assertThat(result.get().getBoolean("col9")).isEqualTo(false); + assertThat(result.get().getBigInt("col10")).isEqualTo(2000L); + } + + @Test + public void get_ForVirtualTable_WithProjection_ShouldGetOnlyProjectedColumns() throws Exception { + // Arrange + storage.put( + Put.newBuilder() + .namespace(namespace) + .table(VIRTUAL_TABLE) + .partitionKey(Key.of("pk1", 1, "pk2", "1")) + .clusteringKey(Key.ofText("ck1", "aaa")) + .intValue("col1", 100) + .textValue("col2", "data1") + .bigIntValue("col3", 1000L) + .floatValue("col4", 10.5f) + .booleanValue("col5", true) + .doubleValue("col6", 20.5) + .textValue("col7", "data2") + .intValue("col8", 200) + .booleanValue("col9", false) + .bigIntValue("col10", 2000L) + .build()); + + // Act - project only col1, col2 from left table and col6, col7 from right table + Optional result = + storage.get( + Get.newBuilder() + .namespace(namespace) + .table(VIRTUAL_TABLE) + .partitionKey(Key.of("pk1", 1, "pk2", "1")) + .clusteringKey(Key.ofText("ck1", "aaa")) + .projections("col1", "col2", "col6", "col7") + .build()); + + // Assert + assertThat(result).isPresent(); + assertThat(result.get().getContainedColumnNames()).containsOnly("col1", "col2", "col6", "col7"); + assertThat(result.get().getInt("col1")).isEqualTo(100); + assertThat(result.get().getText("col2")).isEqualTo("data1"); + assertThat(result.get().getDouble("col6")).isEqualTo(20.5); + assertThat(result.get().getText("col7")).isEqualTo("data2"); + } + + @Test + public void scan_ForVirtualTable_ShouldScanProperly() throws Exception { + // Arrange + storage.put( + Put.newBuilder() + .namespace(namespace) + .table(VIRTUAL_TABLE) + .partitionKey(Key.of("pk1", 1, "pk2", "1")) + .clusteringKey(Key.ofText("ck1", "aaa")) + .intValue("col1", 100) + .textValue("col2", "data1") + .bigIntValue("col3", 1000L) + .floatValue("col4", 10.5f) + .booleanValue("col5", true) + .doubleValue("col6", 20.5) + .textValue("col7", "data2") + .intValue("col8", 200) + .booleanValue("col9", false) + .bigIntValue("col10", 2000L) + .build()); + storage.put( + Put.newBuilder() + .namespace(namespace) + .table(VIRTUAL_TABLE) + .partitionKey(Key.of("pk1", 1, "pk2", "1")) + .clusteringKey(Key.ofText("ck1", "bbb")) + .intValue("col1", 101) + .textValue("col2", "data3") + .bigIntValue("col3", 1001L) + .floatValue("col4", 11.5f) + .booleanValue("col5", false) + .doubleValue("col6", 21.5) + .textValue("col7", "data4") + .intValue("col8", 201) + .booleanValue("col9", true) + .bigIntValue("col10", 2001L) + .build()); + storage.put( + Put.newBuilder() + .namespace(namespace) + .table(VIRTUAL_TABLE) + .partitionKey(Key.of("pk1", 1, "pk2", "1")) + .clusteringKey(Key.ofText("ck1", "ccc")) + .intValue("col1", 102) + .textValue("col2", "data5") + .bigIntValue("col3", 1002L) + .floatValue("col4", 12.5f) + .booleanValue("col5", true) + .doubleValue("col6", 22.5) + .textValue("col7", "data6") + .intValue("col8", 202) + .booleanValue("col9", false) + .bigIntValue("col10", 2002L) + .build()); + + // Act + Scanner scanner = + storage.scan( + Scan.newBuilder() + .namespace(namespace) + .table(VIRTUAL_TABLE) + .partitionKey(Key.of("pk1", 1, "pk2", "1")) + .build()); + List results = scanner.all(); + scanner.close(); + + // Assert + assertThat(results).hasSize(3); + // First record + assertThat(results.get(0).getInt("pk1")).isEqualTo(1); + assertThat(results.get(0).getText("pk2")).isEqualTo("1"); + assertThat(results.get(0).getText("ck1")).isEqualTo("aaa"); + assertThat(results.get(0).getInt("col1")).isEqualTo(100); + assertThat(results.get(0).getText("col2")).isEqualTo("data1"); + assertThat(results.get(0).getBigInt("col3")).isEqualTo(1000L); + assertThat(results.get(0).getFloat("col4")).isEqualTo(10.5f); + assertThat(results.get(0).getBoolean("col5")).isEqualTo(true); + assertThat(results.get(0).getDouble("col6")).isEqualTo(20.5); + assertThat(results.get(0).getText("col7")).isEqualTo("data2"); + assertThat(results.get(0).getInt("col8")).isEqualTo(200); + assertThat(results.get(0).getBoolean("col9")).isEqualTo(false); + assertThat(results.get(0).getBigInt("col10")).isEqualTo(2000L); + // Second record + assertThat(results.get(1).getInt("pk1")).isEqualTo(1); + assertThat(results.get(1).getText("pk2")).isEqualTo("1"); + assertThat(results.get(1).getText("ck1")).isEqualTo("bbb"); + assertThat(results.get(1).getInt("col1")).isEqualTo(101); + assertThat(results.get(1).getText("col2")).isEqualTo("data3"); + assertThat(results.get(1).getBigInt("col3")).isEqualTo(1001L); + assertThat(results.get(1).getFloat("col4")).isEqualTo(11.5f); + assertThat(results.get(1).getBoolean("col5")).isEqualTo(false); + assertThat(results.get(1).getDouble("col6")).isEqualTo(21.5); + assertThat(results.get(1).getText("col7")).isEqualTo("data4"); + assertThat(results.get(1).getInt("col8")).isEqualTo(201); + assertThat(results.get(1).getBoolean("col9")).isEqualTo(true); + assertThat(results.get(1).getBigInt("col10")).isEqualTo(2001L); + // Third record + assertThat(results.get(2).getInt("pk1")).isEqualTo(1); + assertThat(results.get(2).getText("pk2")).isEqualTo("1"); + assertThat(results.get(2).getText("ck1")).isEqualTo("ccc"); + assertThat(results.get(2).getInt("col1")).isEqualTo(102); + assertThat(results.get(2).getText("col2")).isEqualTo("data5"); + assertThat(results.get(2).getBigInt("col3")).isEqualTo(1002L); + assertThat(results.get(2).getFloat("col4")).isEqualTo(12.5f); + assertThat(results.get(2).getBoolean("col5")).isEqualTo(true); + assertThat(results.get(2).getDouble("col6")).isEqualTo(22.5); + assertThat(results.get(2).getText("col7")).isEqualTo("data6"); + assertThat(results.get(2).getInt("col8")).isEqualTo(202); + assertThat(results.get(2).getBoolean("col9")).isEqualTo(false); + assertThat(results.get(2).getBigInt("col10")).isEqualTo(2002L); + } + + @Test + public void scan_ForVirtualTable_WithProjection_ShouldScanOnlyProjectedColumns() + throws Exception { + // Arrange + storage.put( + Put.newBuilder() + .namespace(namespace) + .table(VIRTUAL_TABLE) + .partitionKey(Key.of("pk1", 1, "pk2", "1")) + .clusteringKey(Key.ofText("ck1", "aaa")) + .intValue("col1", 100) + .textValue("col2", "data1") + .bigIntValue("col3", 1000L) + .floatValue("col4", 10.5f) + .booleanValue("col5", true) + .doubleValue("col6", 20.5) + .textValue("col7", "data2") + .intValue("col8", 200) + .booleanValue("col9", false) + .bigIntValue("col10", 2000L) + .build()); + storage.put( + Put.newBuilder() + .namespace(namespace) + .table(VIRTUAL_TABLE) + .partitionKey(Key.of("pk1", 1, "pk2", "1")) + .clusteringKey(Key.ofText("ck1", "bbb")) + .intValue("col1", 101) + .textValue("col2", "data3") + .bigIntValue("col3", 1001L) + .floatValue("col4", 11.5f) + .booleanValue("col5", false) + .doubleValue("col6", 21.5) + .textValue("col7", "data4") + .intValue("col8", 201) + .booleanValue("col9", true) + .bigIntValue("col10", 2001L) + .build()); + + // Act - project only col1, col5 from left table and col8, col9 from right table + Scanner scanner = + storage.scan( + Scan.newBuilder() + .namespace(namespace) + .table(VIRTUAL_TABLE) + .partitionKey(Key.of("pk1", 1, "pk2", "1")) + .projections("col1", "col5", "col8", "col9") + .build()); + List results = scanner.all(); + scanner.close(); + + // Assert + assertThat(results).hasSize(2); + // First record + assertThat(results.get(0).getContainedColumnNames()) + .containsOnly("col1", "col5", "col8", "col9"); + assertThat(results.get(0).getInt("col1")).isEqualTo(100); + assertThat(results.get(0).getBoolean("col5")).isEqualTo(true); + assertThat(results.get(0).getInt("col8")).isEqualTo(200); + assertThat(results.get(0).getBoolean("col9")).isEqualTo(false); + // Second record + assertThat(results.get(1).getContainedColumnNames()) + .containsOnly("col1", "col5", "col8", "col9"); + assertThat(results.get(1).getInt("col1")).isEqualTo(101); + assertThat(results.get(1).getBoolean("col5")).isEqualTo(false); + assertThat(results.get(1).getInt("col8")).isEqualTo(201); + assertThat(results.get(1).getBoolean("col9")).isEqualTo(true); + } + + @Test + public void put_ForVirtualTable_ShouldStoreProperly() throws Exception { + // Arrange + Put put = + Put.newBuilder() + .namespace(namespace) + .table(VIRTUAL_TABLE) + .partitionKey(Key.of("pk1", 1, "pk2", "1")) + .clusteringKey(Key.ofText("ck1", "aaa")) + .intValue("col1", 100) + .textValue("col2", "data1") + .bigIntValue("col3", 1000L) + .floatValue("col4", 10.5f) + .booleanValue("col5", true) + .doubleValue("col6", 20.5) + .textValue("col7", "data2") + .intValue("col8", 200) + .booleanValue("col9", false) + .bigIntValue("col10", 2000L) + .build(); + + // Act + storage.put(put); + + // Assert + Optional result = + storage.get( + Get.newBuilder() + .namespace(namespace) + .table(VIRTUAL_TABLE) + .partitionKey(Key.of("pk1", 1, "pk2", "1")) + .clusteringKey(Key.ofText("ck1", "aaa")) + .build()); + assertThat(result).isPresent(); + assertThat(result.get().getInt("pk1")).isEqualTo(1); + assertThat(result.get().getText("pk2")).isEqualTo("1"); + assertThat(result.get().getText("ck1")).isEqualTo("aaa"); + assertThat(result.get().getInt("col1")).isEqualTo(100); + assertThat(result.get().getText("col2")).isEqualTo("data1"); + assertThat(result.get().getBigInt("col3")).isEqualTo(1000L); + assertThat(result.get().getFloat("col4")).isEqualTo(10.5f); + assertThat(result.get().getBoolean("col5")).isEqualTo(true); + assertThat(result.get().getDouble("col6")).isEqualTo(20.5); + assertThat(result.get().getText("col7")).isEqualTo("data2"); + assertThat(result.get().getInt("col8")).isEqualTo(200); + assertThat(result.get().getBoolean("col9")).isEqualTo(false); + assertThat(result.get().getBigInt("col10")).isEqualTo(2000L); + } + + @Test + public void delete_ForVirtualTable_ShouldDeleteProperly() throws Exception { + // Arrange + storage.put( + Put.newBuilder() + .namespace(namespace) + .table(VIRTUAL_TABLE) + .partitionKey(Key.of("pk1", 1, "pk2", "1")) + .clusteringKey(Key.ofText("ck1", "aaa")) + .intValue("col1", 100) + .textValue("col2", "data1") + .bigIntValue("col3", 1000L) + .floatValue("col4", 10.5f) + .booleanValue("col5", true) + .doubleValue("col6", 20.5) + .textValue("col7", "data2") + .intValue("col8", 200) + .booleanValue("col9", false) + .bigIntValue("col10", 2000L) + .build()); + + // Act + storage.delete( + Delete.newBuilder() + .namespace(namespace) + .table(VIRTUAL_TABLE) + .partitionKey(Key.of("pk1", 1, "pk2", "1")) + .clusteringKey(Key.ofText("ck1", "aaa")) + .build()); + + // Assert + Optional result = + storage.get( + Get.newBuilder() + .namespace(namespace) + .table(VIRTUAL_TABLE) + .partitionKey(Key.of("pk1", 1, "pk2", "1")) + .clusteringKey(Key.ofText("ck1", "aaa")) + .build()); + assertThat(result).isEmpty(); + } + + @Test + public void mutate_ForVirtualTable_ShouldMutateProperly() throws Exception { + // Arrange + storage.put( + Put.newBuilder() + .namespace(namespace) + .table(VIRTUAL_TABLE) + .partitionKey(Key.of("pk1", 1, "pk2", "1")) + .clusteringKey(Key.ofText("ck1", "aaa")) + .intValue("col1", 100) + .textValue("col2", "data1") + .bigIntValue("col3", 1000L) + .floatValue("col4", 10.5f) + .booleanValue("col5", true) + .doubleValue("col6", 20.5) + .textValue("col7", "data2") + .intValue("col8", 200) + .booleanValue("col9", false) + .bigIntValue("col10", 2000L) + .build()); + + // Act + storage.mutate( + Arrays.asList( + Put.newBuilder() + .namespace(namespace) + .table(VIRTUAL_TABLE) + .partitionKey(Key.of("pk1", 2, "pk2", "2")) + .clusteringKey(Key.ofText("ck1", "bbb")) + .intValue("col1", 101) + .textValue("col2", "data3") + .bigIntValue("col3", 1001L) + .floatValue("col4", 11.5f) + .booleanValue("col5", false) + .doubleValue("col6", 21.5) + .textValue("col7", "data4") + .intValue("col8", 201) + .booleanValue("col9", true) + .bigIntValue("col10", 2001L) + .build(), + Delete.newBuilder() + .namespace(namespace) + .table(VIRTUAL_TABLE) + .partitionKey(Key.of("pk1", 1, "pk2", "1")) + .clusteringKey(Key.ofText("ck1", "aaa")) + .build())); + + // Assert + Optional result1 = + storage.get( + Get.newBuilder() + .namespace(namespace) + .table(VIRTUAL_TABLE) + .partitionKey(Key.of("pk1", 2, "pk2", "2")) + .clusteringKey(Key.ofText("ck1", "bbb")) + .build()); + assertThat(result1).isPresent(); + assertThat(result1.get().getInt("pk1")).isEqualTo(2); + assertThat(result1.get().getText("pk2")).isEqualTo("2"); + assertThat(result1.get().getText("ck1")).isEqualTo("bbb"); + assertThat(result1.get().getInt("col1")).isEqualTo(101); + assertThat(result1.get().getText("col2")).isEqualTo("data3"); + assertThat(result1.get().getBigInt("col3")).isEqualTo(1001L); + assertThat(result1.get().getFloat("col4")).isEqualTo(11.5f); + assertThat(result1.get().getBoolean("col5")).isEqualTo(false); + assertThat(result1.get().getDouble("col6")).isEqualTo(21.5); + assertThat(result1.get().getText("col7")).isEqualTo("data4"); + assertThat(result1.get().getInt("col8")).isEqualTo(201); + assertThat(result1.get().getBoolean("col9")).isEqualTo(true); + assertThat(result1.get().getBigInt("col10")).isEqualTo(2001L); + + Optional result2 = + storage.get( + Get.newBuilder() + .namespace(namespace) + .table(VIRTUAL_TABLE) + .partitionKey(Key.of("pk1", 1, "pk2", "1")) + .clusteringKey(Key.ofText("ck1", "aaa")) + .build()); + assertThat(result2).isEmpty(); + } + + @Test + public void putIfExists_ForVirtualTable_ShouldUpdateProperly() throws Exception { + // Arrange + storage.put( + Put.newBuilder() + .namespace(namespace) + .table(VIRTUAL_TABLE) + .partitionKey(Key.of("pk1", 1, "pk2", "1")) + .clusteringKey(Key.ofText("ck1", "aaa")) + .intValue("col1", 100) + .textValue("col2", "data1") + .bigIntValue("col3", 1000L) + .floatValue("col4", 10.5f) + .booleanValue("col5", true) + .doubleValue("col6", 20.5) + .textValue("col7", "data2") + .intValue("col8", 200) + .booleanValue("col9", false) + .bigIntValue("col10", 2000L) + .build()); + + // Act + storage.put( + Put.newBuilder() + .namespace(namespace) + .table(VIRTUAL_TABLE) + .partitionKey(Key.of("pk1", 1, "pk2", "1")) + .clusteringKey(Key.ofText("ck1", "aaa")) + .intValue("col1", 111) + .textValue("col2", "updated1") + .bigIntValue("col3", 1111L) + .floatValue("col4", 11.5f) + .booleanValue("col5", false) + .doubleValue("col6", 21.5) + .textValue("col7", "updated2") + .intValue("col8", 211) + .booleanValue("col9", true) + .bigIntValue("col10", 2111L) + .condition(ConditionBuilder.putIfExists()) + .build()); + + // Assert + Optional result = + storage.get( + Get.newBuilder() + .namespace(namespace) + .table(VIRTUAL_TABLE) + .partitionKey(Key.of("pk1", 1, "pk2", "1")) + .clusteringKey(Key.ofText("ck1", "aaa")) + .build()); + assertThat(result).isPresent(); + assertThat(result.get().getInt("col1")).isEqualTo(111); + assertThat(result.get().getText("col2")).isEqualTo("updated1"); + assertThat(result.get().getBigInt("col3")).isEqualTo(1111L); + assertThat(result.get().getFloat("col4")).isEqualTo(11.5f); + assertThat(result.get().getBoolean("col5")).isEqualTo(false); + assertThat(result.get().getDouble("col6")).isEqualTo(21.5); + assertThat(result.get().getText("col7")).isEqualTo("updated2"); + assertThat(result.get().getInt("col8")).isEqualTo(211); + assertThat(result.get().getBoolean("col9")).isEqualTo(true); + assertThat(result.get().getBigInt("col10")).isEqualTo(2111L); + } + + @Test + public void putIfNotExists_ForVirtualTable_ShouldInsertProperly() throws Exception { + // Arrange - no existing data + + // Act + storage.put( + Put.newBuilder() + .namespace(namespace) + .table(VIRTUAL_TABLE) + .partitionKey(Key.of("pk1", 1, "pk2", "1")) + .clusteringKey(Key.ofText("ck1", "aaa")) + .intValue("col1", 100) + .textValue("col2", "data1") + .bigIntValue("col3", 1000L) + .floatValue("col4", 10.5f) + .booleanValue("col5", true) + .doubleValue("col6", 20.5) + .textValue("col7", "data2") + .intValue("col8", 200) + .booleanValue("col9", false) + .bigIntValue("col10", 2000L) + .condition(ConditionBuilder.putIfNotExists()) + .build()); + + // Assert + Optional result = + storage.get( + Get.newBuilder() + .namespace(namespace) + .table(VIRTUAL_TABLE) + .partitionKey(Key.of("pk1", 1, "pk2", "1")) + .clusteringKey(Key.ofText("ck1", "aaa")) + .build()); + assertThat(result).isPresent(); + assertThat(result.get().getInt("col1")).isEqualTo(100); + assertThat(result.get().getText("col2")).isEqualTo("data1"); + assertThat(result.get().getBigInt("col3")).isEqualTo(1000L); + assertThat(result.get().getFloat("col4")).isEqualTo(10.5f); + assertThat(result.get().getBoolean("col5")).isEqualTo(true); + assertThat(result.get().getDouble("col6")).isEqualTo(20.5); + assertThat(result.get().getText("col7")).isEqualTo("data2"); + assertThat(result.get().getInt("col8")).isEqualTo(200); + assertThat(result.get().getBoolean("col9")).isEqualTo(false); + assertThat(result.get().getBigInt("col10")).isEqualTo(2000L); + } + + @Test + public void putIf_ForVirtualTable_ShouldUpdateConditionally() throws Exception { + // Arrange + storage.put( + Put.newBuilder() + .namespace(namespace) + .table(VIRTUAL_TABLE) + .partitionKey(Key.of("pk1", 1, "pk2", "1")) + .clusteringKey(Key.ofText("ck1", "aaa")) + .intValue("col1", 100) + .textValue("col2", "data1") + .bigIntValue("col3", 1000L) + .floatValue("col4", 10.5f) + .booleanValue("col5", true) + .doubleValue("col6", 20.5) + .textValue("col7", "data2") + .intValue("col8", 200) + .booleanValue("col9", false) + .bigIntValue("col10", 2000L) + .build()); + + // Act + storage.put( + Put.newBuilder() + .namespace(namespace) + .table(VIRTUAL_TABLE) + .partitionKey(Key.of("pk1", 1, "pk2", "1")) + .clusteringKey(Key.ofText("ck1", "aaa")) + .intValue("col1", 111) + .textValue("col2", "updated1") + .bigIntValue("col3", 1111L) + .floatValue("col4", 11.5f) + .booleanValue("col5", false) + .doubleValue("col6", 21.5) + .textValue("col7", "updated2") + .intValue("col8", 211) + .booleanValue("col9", true) + .bigIntValue("col10", 2111L) + .condition( + ConditionBuilder.putIf(ConditionBuilder.column("col1").isEqualToInt(100)) + .and(ConditionBuilder.column("col5").isEqualToBoolean(true)) + .build()) + .build()); + + // Assert + Optional result = + storage.get( + Get.newBuilder() + .namespace(namespace) + .table(VIRTUAL_TABLE) + .partitionKey(Key.of("pk1", 1, "pk2", "1")) + .clusteringKey(Key.ofText("ck1", "aaa")) + .build()); + assertThat(result).isPresent(); + assertThat(result.get().getInt("col1")).isEqualTo(111); + assertThat(result.get().getText("col2")).isEqualTo("updated1"); + assertThat(result.get().getBigInt("col3")).isEqualTo(1111L); + assertThat(result.get().getFloat("col4")).isEqualTo(11.5f); + assertThat(result.get().getBoolean("col5")).isEqualTo(false); + assertThat(result.get().getDouble("col6")).isEqualTo(21.5); + assertThat(result.get().getText("col7")).isEqualTo("updated2"); + assertThat(result.get().getInt("col8")).isEqualTo(211); + assertThat(result.get().getBoolean("col9")).isEqualTo(true); + assertThat(result.get().getBigInt("col10")).isEqualTo(2111L); + } + + @Test + public void putIfExists_ForVirtualTable_WhenRecordDoesNotExist_ShouldThrowNoMutationException() + throws Exception { + // Arrange - no existing data + + // Act Assert + assertThatThrownBy( + () -> + storage.put( + Put.newBuilder() + .namespace(namespace) + .table(VIRTUAL_TABLE) + .partitionKey(Key.of("pk1", 1, "pk2", "1")) + .clusteringKey(Key.ofText("ck1", "aaa")) + .intValue("col1", 100) + .textValue("col2", "data1") + .bigIntValue("col3", 1000L) + .floatValue("col4", 10.5f) + .booleanValue("col5", true) + .doubleValue("col6", 20.5) + .textValue("col7", "data2") + .intValue("col8", 200) + .booleanValue("col9", false) + .bigIntValue("col10", 2000L) + .condition(ConditionBuilder.putIfExists()) + .build())) + .isInstanceOf(NoMutationException.class); + + Optional result = + storage.get( + Get.newBuilder() + .namespace(namespace) + .table(VIRTUAL_TABLE) + .partitionKey(Key.of("pk1", 1, "pk2", "1")) + .clusteringKey(Key.ofText("ck1", "aaa")) + .build()); + assertThat(result).isEmpty(); + } + + @Test + public void putIfNotExists_ForVirtualTable_WhenRecordExists_ShouldThrowNoMutationException() + throws Exception { + // Arrange + storage.put( + Put.newBuilder() + .namespace(namespace) + .table(VIRTUAL_TABLE) + .partitionKey(Key.of("pk1", 1, "pk2", "1")) + .clusteringKey(Key.ofText("ck1", "aaa")) + .intValue("col1", 100) + .textValue("col2", "data1") + .bigIntValue("col3", 1000L) + .floatValue("col4", 10.5f) + .booleanValue("col5", true) + .doubleValue("col6", 20.5) + .textValue("col7", "data2") + .intValue("col8", 200) + .booleanValue("col9", false) + .bigIntValue("col10", 2000L) + .build()); + + // Act Assert + assertThatThrownBy( + () -> + storage.put( + Put.newBuilder() + .namespace(namespace) + .table(VIRTUAL_TABLE) + .partitionKey(Key.of("pk1", 1, "pk2", "1")) + .clusteringKey(Key.ofText("ck1", "aaa")) + .intValue("col1", 111) + .textValue("col2", "updated1") + .bigIntValue("col3", 1111L) + .floatValue("col4", 11.5f) + .booleanValue("col5", false) + .doubleValue("col6", 21.5) + .textValue("col7", "updated2") + .intValue("col8", 211) + .booleanValue("col9", true) + .bigIntValue("col10", 2111L) + .condition(ConditionBuilder.putIfNotExists()) + .build())) + .isInstanceOf(NoMutationException.class); + + // Verify original data remains unchanged + Optional result = + storage.get( + Get.newBuilder() + .namespace(namespace) + .table(VIRTUAL_TABLE) + .partitionKey(Key.of("pk1", 1, "pk2", "1")) + .clusteringKey(Key.ofText("ck1", "aaa")) + .build()); + assertThat(result).isPresent(); + assertThat(result.get().getInt("col1")).isEqualTo(100); + assertThat(result.get().getText("col2")).isEqualTo("data1"); + } + + @Test + public void putIf_ForVirtualTable_WhenConditionNotMet_ShouldThrowNoMutationException() + throws Exception { + // Arrange + storage.put( + Put.newBuilder() + .namespace(namespace) + .table(VIRTUAL_TABLE) + .partitionKey(Key.of("pk1", 1, "pk2", "1")) + .clusteringKey(Key.ofText("ck1", "aaa")) + .intValue("col1", 100) + .textValue("col2", "data1") + .bigIntValue("col3", 1000L) + .floatValue("col4", 10.5f) + .booleanValue("col5", true) + .doubleValue("col6", 20.5) + .textValue("col7", "data2") + .intValue("col8", 200) + .booleanValue("col9", false) + .bigIntValue("col10", 2000L) + .build()); + + // Act Assert - condition col1 == 999 should fail + assertThatThrownBy( + () -> + storage.put( + Put.newBuilder() + .namespace(namespace) + .table(VIRTUAL_TABLE) + .partitionKey(Key.of("pk1", 1, "pk2", "1")) + .clusteringKey(Key.ofText("ck1", "aaa")) + .intValue("col1", 111) + .textValue("col2", "updated1") + .bigIntValue("col3", 1111L) + .floatValue("col4", 11.5f) + .booleanValue("col5", false) + .doubleValue("col6", 21.5) + .textValue("col7", "updated2") + .intValue("col8", 211) + .booleanValue("col9", true) + .bigIntValue("col10", 2111L) + .condition( + ConditionBuilder.putIf( + ConditionBuilder.column("col1").isEqualToInt(999)) + .build()) + .build())) + .isInstanceOf(NoMutationException.class); + + // Verify original data remains unchanged + Optional result = + storage.get( + Get.newBuilder() + .namespace(namespace) + .table(VIRTUAL_TABLE) + .partitionKey(Key.of("pk1", 1, "pk2", "1")) + .clusteringKey(Key.ofText("ck1", "aaa")) + .build()); + assertThat(result).isPresent(); + assertThat(result.get().getInt("col1")).isEqualTo(100); + assertThat(result.get().getText("col2")).isEqualTo("data1"); + } + + @Test + public void deleteIfExists_ForVirtualTable_ShouldDeleteProperly() throws Exception { + // Arrange + storage.put( + Put.newBuilder() + .namespace(namespace) + .table(VIRTUAL_TABLE) + .partitionKey(Key.of("pk1", 1, "pk2", "1")) + .clusteringKey(Key.ofText("ck1", "aaa")) + .intValue("col1", 100) + .textValue("col2", "data1") + .bigIntValue("col3", 1000L) + .floatValue("col4", 10.5f) + .booleanValue("col5", true) + .doubleValue("col6", 20.5) + .textValue("col7", "data2") + .intValue("col8", 200) + .booleanValue("col9", false) + .bigIntValue("col10", 2000L) + .build()); + + // Act + storage.delete( + Delete.newBuilder() + .namespace(namespace) + .table(VIRTUAL_TABLE) + .partitionKey(Key.of("pk1", 1, "pk2", "1")) + .clusteringKey(Key.ofText("ck1", "aaa")) + .condition(ConditionBuilder.deleteIfExists()) + .build()); + + // Assert + Optional result = + storage.get( + Get.newBuilder() + .namespace(namespace) + .table(VIRTUAL_TABLE) + .partitionKey(Key.of("pk1", 1, "pk2", "1")) + .clusteringKey(Key.ofText("ck1", "aaa")) + .build()); + assertThat(result).isEmpty(); + } + + @Test + public void deleteIf_ForVirtualTable_ShouldDeleteConditionally() throws Exception { + // Arrange + storage.put( + Put.newBuilder() + .namespace(namespace) + .table(VIRTUAL_TABLE) + .partitionKey(Key.of("pk1", 1, "pk2", "1")) + .clusteringKey(Key.ofText("ck1", "aaa")) + .intValue("col1", 100) + .textValue("col2", "data1") + .bigIntValue("col3", 1000L) + .floatValue("col4", 10.5f) + .booleanValue("col5", true) + .doubleValue("col6", 20.5) + .textValue("col7", "data2") + .intValue("col8", 200) + .booleanValue("col9", false) + .bigIntValue("col10", 2000L) + .build()); + + // Act + storage.delete( + Delete.newBuilder() + .namespace(namespace) + .table(VIRTUAL_TABLE) + .partitionKey(Key.of("pk1", 1, "pk2", "1")) + .clusteringKey(Key.ofText("ck1", "aaa")) + .condition( + ConditionBuilder.deleteIf(ConditionBuilder.column("col1").isEqualToInt(100)) + .and(ConditionBuilder.column("col5").isEqualToBoolean(true)) + .build()) + .build()); + + // Assert + Optional result = + storage.get( + Get.newBuilder() + .namespace(namespace) + .table(VIRTUAL_TABLE) + .partitionKey(Key.of("pk1", 1, "pk2", "1")) + .clusteringKey(Key.ofText("ck1", "aaa")) + .build()); + assertThat(result).isEmpty(); + } + + @Test + public void deleteIfExists_ForVirtualTable_WhenRecordDoesNotExist_ShouldThrowNoMutationException() + throws Exception { + // Arrange - no existing data + + // Act Assert + assertThatThrownBy( + () -> + storage.delete( + Delete.newBuilder() + .namespace(namespace) + .table(VIRTUAL_TABLE) + .partitionKey(Key.of("pk1", 1, "pk2", "1")) + .clusteringKey(Key.ofText("ck1", "aaa")) + .condition(ConditionBuilder.deleteIfExists()) + .build())) + .isInstanceOf(NoMutationException.class); + + Optional result = + storage.get( + Get.newBuilder() + .namespace(namespace) + .table(VIRTUAL_TABLE) + .partitionKey(Key.of("pk1", 1, "pk2", "1")) + .clusteringKey(Key.ofText("ck1", "aaa")) + .build()); + assertThat(result).isEmpty(); + } + + @Test + public void deleteIf_ForVirtualTable_WhenConditionNotMet_ShouldThrowNoMutationException() + throws Exception { + // Arrange + storage.put( + Put.newBuilder() + .namespace(namespace) + .table(VIRTUAL_TABLE) + .partitionKey(Key.of("pk1", 1, "pk2", "1")) + .clusteringKey(Key.ofText("ck1", "aaa")) + .intValue("col1", 100) + .textValue("col2", "data1") + .bigIntValue("col3", 1000L) + .floatValue("col4", 10.5f) + .booleanValue("col5", true) + .doubleValue("col6", 20.5) + .textValue("col7", "data2") + .intValue("col8", 200) + .booleanValue("col9", false) + .bigIntValue("col10", 2000L) + .build()); + + // Act Assert - condition col1 == 999 should fail + assertThatThrownBy( + () -> + storage.delete( + Delete.newBuilder() + .namespace(namespace) + .table(VIRTUAL_TABLE) + .partitionKey(Key.of("pk1", 1, "pk2", "1")) + .clusteringKey(Key.ofText("ck1", "aaa")) + .condition( + ConditionBuilder.deleteIf( + ConditionBuilder.column("col1").isEqualToInt(999)) + .build()) + .build())) + .isInstanceOf(NoMutationException.class); + + // Verify original data remains unchanged + Optional result = + storage.get( + Get.newBuilder() + .namespace(namespace) + .table(VIRTUAL_TABLE) + .partitionKey(Key.of("pk1", 1, "pk2", "1")) + .clusteringKey(Key.ofText("ck1", "aaa")) + .build()); + assertThat(result).isPresent(); + assertThat(result.get().getInt("col1")).isEqualTo(100); + } +}