From eff837850126e740d2444021183724edd0dfd927 Mon Sep 17 00:00:00 2001 From: Toshihiro Suzuki Date: Tue, 25 Nov 2025 14:58:17 +0900 Subject: [PATCH] Revisit handling IS_NULL conditions on right source table columns in LEFT_OUTER virtual tables (#3217) --- .../java/com/scalar/db/common/CoreError.java | 6 + .../scalar/db/storage/jdbc/JdbcDatabase.java | 42 +- .../storage/jdbc/JdbcOperationAttributes.java | 49 +++ .../db/storage/jdbc/JdbcDatabaseTest.java | 414 ++++++++++++++++++ .../jdbc/JdbcOperationAttributesTest.java | 178 ++++++++ 5 files changed, 687 insertions(+), 2 deletions(-) create mode 100644 core/src/main/java/com/scalar/db/storage/jdbc/JdbcOperationAttributes.java create mode 100644 core/src/test/java/com/scalar/db/storage/jdbc/JdbcOperationAttributesTest.java 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 6676037030..fd22551af1 100644 --- a/core/src/main/java/com/scalar/db/common/CoreError.java +++ b/core/src/main/java/com/scalar/db/common/CoreError.java @@ -1009,6 +1009,12 @@ public enum CoreError implements ScalarDbError { "Source tables cannot be dropped while virtual tables depending on them exist. Source table: %s; Virtual tables: %s", "", ""), + DELETE_IF_IS_NULL_FOR_RIGHT_SOURCE_TABLE_NOT_ALLOWED_FOR_LEFT_OUTER_VIRTUAL_TABLES( + Category.USER_ERROR, + "0276", + "The DeleteIf IS_NULL condition for right source table columns is not allowed in LEFT_OUTER virtual tables. Virtual table: %s", + "", + ""), // // Errors for the concurrency error category 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 5411c29462..54a96e353d 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 @@ -35,6 +35,7 @@ import com.scalar.db.exception.storage.NoMutationException; import com.scalar.db.exception.storage.RetriableExecutionException; import com.scalar.db.io.Column; +import com.scalar.db.util.ScalarDbUtils; import java.sql.Connection; import java.sql.SQLException; import java.util.ArrayList; @@ -395,7 +396,19 @@ private List dividePutForSourceTables(Put put, VirtualTableInfo virtualTabl putBuilderForLeftSourceTable.condition(ConditionBuilder.putIf(leftExpressions)); } if (!rightExpressions.isEmpty()) { - putBuilderForRightSourceTable.condition(ConditionBuilder.putIf(rightExpressions)); + if (isAllIsNullOnRightColumnsInLeftOuterJoin(virtualTableInfo, rightExpressions) + && JdbcOperationAttributes + .isLeftOuterVirtualTablePutIfIsNullOnRightColumnsConversionEnabled(put)) { + // In a LEFT_OUTER join, when all conditions on the right source table columns are + // IS_NULL, we cannot distinguish whether we should check for the existence of a + // right-side record with NULL values or for the case where the right-side record does + // not exist at all. Therefore, this behavior is controlled by the operation attribute. + // By default, we convert the condition to PutIfNotExists, assuming that the more common + // use case is to check that the right-side record does not exist. + putBuilderForRightSourceTable.condition(ConditionBuilder.putIfNotExists()); + } else { + putBuilderForRightSourceTable.condition(ConditionBuilder.putIf(rightExpressions)); + } } } } @@ -464,7 +477,25 @@ private List divideDeleteForSourceTables(Delete delete, VirtualTableInfo deleteBuilderForLeftSourceTable.condition(ConditionBuilder.deleteIf(leftExpressions)); } if (!rightExpressions.isEmpty()) { - deleteBuilderForRightSourceTable.condition(ConditionBuilder.deleteIf(rightExpressions)); + if (isAllIsNullOnRightColumnsInLeftOuterJoin(virtualTableInfo, rightExpressions) + && !JdbcOperationAttributes + .isLeftOuterVirtualTableDeleteIfIsNullOnRightColumnsAllowed(delete)) { + // In a LEFT_OUTER join, when all conditions on the right source table columns are + // IS_NULL, we cannot distinguish whether we should check for the existence of a + // right-side record with NULL values or for the case where the right-side record does + // not exist at all. This makes the delete operation semantically ambiguous. Therefore, + // this behavior is controlled by the operation attribute. By default, we disallow this + // operation to prevent unintended behavior. + assert delete.forNamespace().isPresent() && delete.forTable().isPresent(); + throw new IllegalArgumentException( + CoreError + .DELETE_IF_IS_NULL_FOR_RIGHT_SOURCE_TABLE_NOT_ALLOWED_FOR_LEFT_OUTER_VIRTUAL_TABLES + .buildMessage( + ScalarDbUtils.getFullTableName( + delete.forNamespace().get(), delete.forTable().get()))); + } else { + deleteBuilderForRightSourceTable.condition(ConditionBuilder.deleteIf(rightExpressions)); + } } } } @@ -474,6 +505,13 @@ private List divideDeleteForSourceTables(Delete delete, VirtualTableInfo return Arrays.asList(deleteForLeftSourceTable, deleteForRightSourceTable); } + private boolean isAllIsNullOnRightColumnsInLeftOuterJoin( + VirtualTableInfo virtualTableInfo, List rightExpressions) { + return virtualTableInfo.getJoinType() == VirtualTableJoinType.LEFT_OUTER + && rightExpressions.stream() + .allMatch(e -> e.getOperator() == ConditionalExpression.Operator.IS_NULL); + } + private void close(Connection connection) { try { if (connection != null) { diff --git a/core/src/main/java/com/scalar/db/storage/jdbc/JdbcOperationAttributes.java b/core/src/main/java/com/scalar/db/storage/jdbc/JdbcOperationAttributes.java new file mode 100644 index 0000000000..ddc44c9b0a --- /dev/null +++ b/core/src/main/java/com/scalar/db/storage/jdbc/JdbcOperationAttributes.java @@ -0,0 +1,49 @@ +package com.scalar.db.storage.jdbc; + +import com.scalar.db.api.Delete; +import com.scalar.db.api.Put; +import java.util.Map; + +/** A utility class to manipulate the operation attributes for JDBC. */ +public final class JdbcOperationAttributes { + private static final String OPERATION_ATTRIBUTE_PREFIX = "jdbc-"; + + public static final String + LEFT_OUTER_VIRTUAL_TABLE_PUT_IF_IS_NULL_ON_RIGHT_COLUMNS_CONVERSION_ENABLED = + OPERATION_ATTRIBUTE_PREFIX + + "left-outer-virtual-table-put-if-is-null-on-right-columns-conversion-enabled"; + + public static final String LEFT_OUTER_VIRTUAL_TABLE_DELETE_IF_IS_NULL_ON_RIGHT_COLUMNS_ALLOWED = + OPERATION_ATTRIBUTE_PREFIX + + "left-outer-virtual-table-delete-if-is-null-on-right-columns-allowed"; + + private JdbcOperationAttributes() {} + + public static boolean isLeftOuterVirtualTablePutIfIsNullOnRightColumnsConversionEnabled(Put put) { + return put.getAttribute( + LEFT_OUTER_VIRTUAL_TABLE_PUT_IF_IS_NULL_ON_RIGHT_COLUMNS_CONVERSION_ENABLED) + .map(Boolean::parseBoolean) + .orElse(true); + } + + public static void setLeftOuterVirtualTablePutIfIsNullOnRightColumnsConversionEnabled( + Map attributes, boolean enabled) { + attributes.put( + LEFT_OUTER_VIRTUAL_TABLE_PUT_IF_IS_NULL_ON_RIGHT_COLUMNS_CONVERSION_ENABLED, + Boolean.toString(enabled)); + } + + public static boolean isLeftOuterVirtualTableDeleteIfIsNullOnRightColumnsAllowed(Delete delete) { + return delete + .getAttribute(LEFT_OUTER_VIRTUAL_TABLE_DELETE_IF_IS_NULL_ON_RIGHT_COLUMNS_ALLOWED) + .map(Boolean::parseBoolean) + .orElse(false); + } + + public static void setLeftOuterVirtualTableDeleteIfIsNullOnRightColumnsAllowed( + Map attributes, boolean allowed) { + attributes.put( + LEFT_OUTER_VIRTUAL_TABLE_DELETE_IF_IS_NULL_ON_RIGHT_COLUMNS_ALLOWED, + Boolean.toString(allowed)); + } +} 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 4de7866a94..06459e6a1f 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 @@ -34,7 +34,9 @@ import java.sql.ResultSet; import java.sql.SQLException; import java.util.Arrays; +import java.util.HashMap; import java.util.List; +import java.util.Map; import org.apache.commons.dbcp2.BasicDataSource; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -784,6 +786,235 @@ public void put_ForVirtualTableWithPutIf_ShouldDivideConditionsBasedOnColumns() verify(connection).close(); } + @Test + public void + put_ForVirtualTableWithLeftOuterJoinAndAllIsNullConditionsOnRightTable_ShouldUsePutIfNotExists() + 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.putIf( + ConditionBuilder.column("left_col1").isEqualToText("check_val")) + .and(ConditionBuilder.column("right_col1").isNullText()) + .and(ConditionBuilder.column("right_col2").isNullInt()) + .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.putIfNotExists()) + .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_ForVirtualTableWithLeftOuterJoinAndMixedConditionsOnRightTable_ShouldUsePutIfWithAllConditions() + 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.putIf( + ConditionBuilder.column("left_col1").isEqualToText("check_val")) + .and(ConditionBuilder.column("right_col1").isNullText()) + .and(ConditionBuilder.column("right_col2").isEqualToInt(123)) + .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").isNullText()) + .and(ConditionBuilder.column("right_col2").isEqualToInt(123)) + .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 + put_ForVirtualTableWithLeftOuterJoinAndAllIsNullConditionsOnRightTableWithDisabledConversion_ShouldUsePutIfWithAllConditions() + 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); + + Map attributes = new HashMap<>(); + JdbcOperationAttributes.setLeftOuterVirtualTablePutIfIsNullOnRightColumnsConversionEnabled( + attributes, false); + + 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").isNullText()) + .and(ConditionBuilder.column("right_col2").isNullInt()) + .build()) + .consistency(Consistency.LINEARIZABLE) + .attributes(attributes) + .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) + .attributes(attributes) + .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").isNullText()) + .and(ConditionBuilder.column("right_col2").isNullInt()) + .build()) + .consistency(Consistency.LINEARIZABLE) + .attributes(attributes) + .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() @@ -972,6 +1203,189 @@ public void delete_ForVirtualTableWithDeleteIf_ShouldDivideConditionsBasedOnColu verify(connection).close(); } + @Test + public void + delete_ForVirtualTableWithLeftOuterJoinAndAllIsNullConditionsOnRightTable_ShouldThrowException() + 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()); + + 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").isNullText()) + .and(ConditionBuilder.column("right_col2").isNullInt()) + .build()) + .consistency(Consistency.LINEARIZABLE) + .build(); + + // Act & Assert + assertThatThrownBy(() -> jdbcDatabase.delete(delete)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void + delete_ForVirtualTableWithLeftOuterJoinAndMixedConditionsOnRightTable_ShouldUseDeleteIfWithAllConditions() + 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.deleteIf( + ConditionBuilder.column("left_col1").isEqualToText("check_val")) + .and(ConditionBuilder.column("right_col1").isNullText()) + .and(ConditionBuilder.column("right_col2").isEqualToInt(123)) + .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").isNullText()) + .and(ConditionBuilder.column("right_col2").isEqualToInt(123)) + .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 + delete_ForVirtualTableWithLeftOuterJoinAndAllIsNullConditionsOnRightTableWithAllowedAttribute_ShouldDeleteProperly() + 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); + + Map attributes = new HashMap<>(); + JdbcOperationAttributes.setLeftOuterVirtualTableDeleteIfIsNullOnRightColumnsAllowed( + attributes, 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").isNullText()) + .and(ConditionBuilder.column("right_col2").isNullInt()) + .build()) + .consistency(Consistency.LINEARIZABLE) + .attributes(attributes) + .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) + .attributes(attributes) + .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").isNullText()) + .and(ConditionBuilder.column("right_col2").isNullInt()) + .build()) + .consistency(Consistency.LINEARIZABLE) + .attributes(attributes) + .build(); + + @SuppressWarnings("unchecked") + ArgumentCaptor> mutationsCaptor = ArgumentCaptor.forClass(List.class); + verify(jdbcService).mutate(mutationsCaptor.capture(), any()); + + List mutations = mutationsCaptor.getValue(); + // For LEFT_OUTER join with IS_NULL conditions allowed, both deletes should be created + 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 { diff --git a/core/src/test/java/com/scalar/db/storage/jdbc/JdbcOperationAttributesTest.java b/core/src/test/java/com/scalar/db/storage/jdbc/JdbcOperationAttributesTest.java new file mode 100644 index 0000000000..0e953f9c53 --- /dev/null +++ b/core/src/test/java/com/scalar/db/storage/jdbc/JdbcOperationAttributesTest.java @@ -0,0 +1,178 @@ +package com.scalar.db.storage.jdbc; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.scalar.db.api.Delete; +import com.scalar.db.api.Put; +import com.scalar.db.io.Key; +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; + +public class JdbcOperationAttributesTest { + + @Test + public void + isLeftOuterVirtualTablePutIfIsNullOnRightColumnsConversionEnabled_WithoutAttribute_ShouldReturnTrue() { + // Arrange + Put put = + Put.newBuilder().namespace("ns").table("tbl").partitionKey(Key.ofInt("pk", 1)).build(); + + // Act + boolean result = + JdbcOperationAttributes.isLeftOuterVirtualTablePutIfIsNullOnRightColumnsConversionEnabled( + put); + + // Assert + assertThat(result).isTrue(); + } + + @Test + public void + isLeftOuterVirtualTablePutIfIsNullOnRightColumnsConversionEnabled_WithTrueAttribute_ShouldReturnTrue() { + // Arrange + Map attributes = new HashMap<>(); + JdbcOperationAttributes.setLeftOuterVirtualTablePutIfIsNullOnRightColumnsConversionEnabled( + attributes, true); + + Put put = + Put.newBuilder() + .namespace("ns") + .table("tbl") + .partitionKey(Key.ofInt("pk", 1)) + .attributes(attributes) + .build(); + + // Act + boolean result = + JdbcOperationAttributes.isLeftOuterVirtualTablePutIfIsNullOnRightColumnsConversionEnabled( + put); + + // Assert + assertThat(result).isTrue(); + } + + @Test + public void + isLeftOuterVirtualTablePutIfIsNullOnRightColumnsConversionEnabled_WithFalseAttribute_ShouldReturnFalse() { + // Arrange + Map attributes = new HashMap<>(); + JdbcOperationAttributes.setLeftOuterVirtualTablePutIfIsNullOnRightColumnsConversionEnabled( + attributes, false); + + Put put = + Put.newBuilder() + .namespace("ns") + .table("tbl") + .partitionKey(Key.ofInt("pk", 1)) + .attributes(attributes) + .build(); + + // Act + boolean result = + JdbcOperationAttributes.isLeftOuterVirtualTablePutIfIsNullOnRightColumnsConversionEnabled( + put); + + // Assert + assertThat(result).isFalse(); + } + + @Test + public void + setLeftOuterVirtualTablePutIfIsNullOnRightColumnsConversionEnabled_ShouldSetAttributeProperly() { + // Arrange + Map attributes = new HashMap<>(); + + // Act + JdbcOperationAttributes.setLeftOuterVirtualTablePutIfIsNullOnRightColumnsConversionEnabled( + attributes, false); + + // Assert + assertThat(attributes) + .containsEntry( + JdbcOperationAttributes + .LEFT_OUTER_VIRTUAL_TABLE_PUT_IF_IS_NULL_ON_RIGHT_COLUMNS_CONVERSION_ENABLED, + "false"); + } + + @Test + public void + isLeftOuterVirtualTableDeleteIfIsNullOnRightColumnsAllowed_WithoutAttribute_ShouldReturnFalse() { + // Arrange + Delete delete = + Delete.newBuilder().namespace("ns").table("tbl").partitionKey(Key.ofInt("pk", 1)).build(); + + // Act + boolean result = + JdbcOperationAttributes.isLeftOuterVirtualTableDeleteIfIsNullOnRightColumnsAllowed(delete); + + // Assert + assertThat(result).isFalse(); + } + + @Test + public void + isLeftOuterVirtualTableDeleteIfIsNullOnRightColumnsAllowed_WithTrueAttribute_ShouldReturnTrue() { + // Arrange + Map attributes = new HashMap<>(); + JdbcOperationAttributes.setLeftOuterVirtualTableDeleteIfIsNullOnRightColumnsAllowed( + attributes, true); + + Delete delete = + Delete.newBuilder() + .namespace("ns") + .table("tbl") + .partitionKey(Key.ofInt("pk", 1)) + .attributes(attributes) + .build(); + + // Act + boolean result = + JdbcOperationAttributes.isLeftOuterVirtualTableDeleteIfIsNullOnRightColumnsAllowed(delete); + + // Assert + assertThat(result).isTrue(); + } + + @Test + public void + isLeftOuterVirtualTableDeleteIfIsNullOnRightColumnsAllowed_WithFalseAttribute_ShouldReturnFalse() { + // Arrange + Map attributes = new HashMap<>(); + JdbcOperationAttributes.setLeftOuterVirtualTableDeleteIfIsNullOnRightColumnsAllowed( + attributes, false); + + Delete delete = + Delete.newBuilder() + .namespace("ns") + .table("tbl") + .partitionKey(Key.ofInt("pk", 1)) + .attributes(attributes) + .build(); + + // Act + boolean result = + JdbcOperationAttributes.isLeftOuterVirtualTableDeleteIfIsNullOnRightColumnsAllowed(delete); + + // Assert + assertThat(result).isFalse(); + } + + @Test + public void + setLeftOuterVirtualTableDeleteIfIsNullOnRightColumnsAllowed_ShouldSetAttributeProperly() { + // Arrange + Map attributes = new HashMap<>(); + + // Act + JdbcOperationAttributes.setLeftOuterVirtualTableDeleteIfIsNullOnRightColumnsAllowed( + attributes, true); + + // Assert + assertThat(attributes) + .containsEntry( + JdbcOperationAttributes + .LEFT_OUTER_VIRTUAL_TABLE_DELETE_IF_IS_NULL_ON_RIGHT_COLUMNS_ALLOWED, + "true"); + } +}