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:
+ *
+ *
+ * The join is performed on the primary-key columns of both sources, which must share the
+ * same schema (columns, order, and types).
+ * Row set depends on {@code joinType}:
+ *
+ * {@code INNER}: only keys present in both sources.
+ * {@code LEFT_OUTER}: all keys from the left; for left-only keys, the right-side
+ * columns appear as {@code NULL}.
+ *
+ * Column order: [primary key columns] + [left non-key columns] + [right non-key columns].
+ * No non-key column name conflicts between sources are allowed.
+ * Both sources must reside within the atomicity unit of the underlying storage, meaning
+ * that the atomicity unit must be at least at the namespace level.
+ * Currently, using virtual tables as sources is not supported.
+ *
+ *
+ * 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 extends Mutation> 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 extends Mutation> mutations) throws ExecutionException {
Connection connection = null;
try {
connection = dataSource.getConnection();
@@ -238,6 +312,168 @@ public void mutate(List extends Mutation> 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);
+ }
+}