This is an automated email from the ASF dual-hosted git repository.

virajjasani pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/phoenix-adapters.git


The following commit(s) were added to refs/heads/main by this push:
     new 9ebc6d2  DeleteItem changes for conditional deletes
9ebc6d2 is described below

commit 9ebc6d2a136d11e1881a7e694299ccd8c17981b9
Author: Palash Chauhan <[email protected]>
AuthorDate: Thu Feb 26 23:12:29 2026 -0800

    DeleteItem changes for conditional deletes
---
 .../phoenix/ddb/service/DeleteItemService.java     |  15 +-
 .../phoenix/ddb/service/UpdateItemService.java     |  35 +-
 .../apache/phoenix/ddb/service/utils/DMLUtils.java |  31 +-
 .../java/org/apache/phoenix/ddb/DeleteItem2IT.java | 758 +++++++++++++++++++++
 .../phoenix/ddb/utils/CommonServiceUtils.java      |  45 ++
 5 files changed, 837 insertions(+), 47 deletions(-)

diff --git 
a/phoenix-ddb-rest/src/main/java/org/apache/phoenix/ddb/service/DeleteItemService.java
 
b/phoenix-ddb-rest/src/main/java/org/apache/phoenix/ddb/service/DeleteItemService.java
index 68262ff..c5166d0 100644
--- 
a/phoenix-ddb-rest/src/main/java/org/apache/phoenix/ddb/service/DeleteItemService.java
+++ 
b/phoenix-ddb-rest/src/main/java/org/apache/phoenix/ddb/service/DeleteItemService.java
@@ -75,10 +75,17 @@ public class DeleteItemService {
 
         boolean hasCondExp = (request.get(ApiMetadata.CONDITION_EXPRESSION) != 
null) || (
                 request.get(ApiMetadata.EXPECTED) != null);
+        boolean canEvaluateCondExprOnEmptyDoc = true;
+        if (hasCondExp && stmtInfo.condExpr != null) {
+            canEvaluateCondExprOnEmptyDoc = 
CommonServiceUtils.evaluateConditionOnNonExistingItem(
+                    stmtInfo.condExpr,
+                    (Map<String, String>) 
request.get(ApiMetadata.EXPRESSION_ATTRIBUTE_NAMES)
+            );
+        }
         return DMLUtils.executeUpdate(stmtInfo.stmt,
                 (String) request.get(ApiMetadata.RETURN_VALUES),
                 (String) 
request.get(ApiMetadata.RETURN_VALUES_ON_CONDITION_CHECK_FAILURE),
-                hasCondExp, true, pkCols, ApiOperation.DELETE_ITEM);
+                hasCondExp, canEvaluateCondExprOnEmptyDoc, pkCols, 
ApiOperation.DELETE_ITEM);
     }
 
     /**
@@ -141,7 +148,7 @@ public class DeleteItemService {
                         
CommonServiceUtils.getEscapedArgument(partitionKeyPKCol)));
             }
         }
-        return new StatementInfo(stmt, conditionDoc);
+        return new StatementInfo(stmt, conditionDoc, condExpr);
     }
 
     /**
@@ -166,10 +173,12 @@ public class DeleteItemService {
     private static class StatementInfo {
         final PreparedStatement stmt;
         final BsonDocument conditionDoc;
+        final String condExpr;
 
-        StatementInfo(PreparedStatement stmt, BsonDocument conditionDoc) {
+        StatementInfo(PreparedStatement stmt, BsonDocument conditionDoc, 
String condExpr) {
             this.stmt = stmt;
             this.conditionDoc = conditionDoc;
+            this.condExpr = condExpr;
         }
     }
 }
diff --git 
a/phoenix-ddb-rest/src/main/java/org/apache/phoenix/ddb/service/UpdateItemService.java
 
b/phoenix-ddb-rest/src/main/java/org/apache/phoenix/ddb/service/UpdateItemService.java
index e478441..cdcd9b7 100644
--- 
a/phoenix-ddb-rest/src/main/java/org/apache/phoenix/ddb/service/UpdateItemService.java
+++ 
b/phoenix-ddb-rest/src/main/java/org/apache/phoenix/ddb/service/UpdateItemService.java
@@ -26,7 +26,6 @@ import org.apache.phoenix.ddb.utils.ApiMetadata;
 import org.apache.phoenix.ddb.utils.PhoenixUtils;
 import org.apache.phoenix.ddb.rest.metrics.ApiOperation;
 import org.apache.phoenix.ddb.utils.CommonServiceUtils;
-import org.apache.phoenix.expression.util.bson.SQLComparisonExpressionUtils;
 import org.apache.phoenix.schema.PColumn;
 
 public class UpdateItemService {
@@ -69,9 +68,6 @@ public class UpdateItemService {
                     + " COL = CASE WHEN BSON_CONDITION_EXPRESSION(COL,?) "
                     + " THEN BSON_UPDATE_EXPRESSION(COL,?) \n" + " ELSE COL 
END";
 
-    private static final BsonDocument EMPTY_BSON_DOC = new BsonDocument();
-    private static final RawBsonDocument EMPTY_RAW_BSON_DOC = 
RawBsonDocument.parse("{}");
-
     public static Map<String, Object> updateItem(Map<String, Object> request,
             String connectionUrl) {
         ValidationUtil.validateUpdateItemRequest(request);
@@ -254,7 +250,7 @@ public class UpdateItemService {
 
         if (hasCondition) {
             // Evaluate if condition can be satisfied on non-existing item
-            canCreateNewItemWithCondition = 
evaluateConditionOnNonExistingItem(condExpr, exprAttrNames);
+            canCreateNewItemWithCondition = 
CommonServiceUtils.evaluateConditionOnNonExistingItem(condExpr, exprAttrNames);
         }
 
         if (canCreateNewItemWithCondition && canEvaluateUpdateExprOnEmptyDoc) {
@@ -287,35 +283,6 @@ public class UpdateItemService {
         }
     }
 
-    /**
-     * Evaluate if a condition expression can be satisfied on a non-existing 
item.
-     * This determines whether UPDATE (allows creation) or UPDATE_ONLY 
(existing only) should be used.
-     * <p>
-     * DynamoDB semantics:
-     * - Non-existing items are treated as empty documents
-     * - Function calls on non-existing attributes typically return false/null
-     * - Existence checks (attribute_exists) return false
-     * - Value comparisons with non-existing attributes return false
-     * <p>
-     */
-    private static boolean evaluateConditionOnNonExistingItem(String condExpr,
-            Map<String, String> exprAttrNames) {
-        try {
-            BsonDocument exprAttrNamesDoc =
-                    
CommonServiceUtils.getExpressionAttributeNamesDoc(exprAttrNames);
-            boolean result = 
SQLComparisonExpressionUtils.evaluateConditionExpression(condExpr,
-                    EMPTY_RAW_BSON_DOC, EMPTY_BSON_DOC, exprAttrNamesDoc);
-
-            LOGGER.debug("Condition '{}' evaluation on empty document: {}", 
condExpr, result);
-            return result;
-        } catch (Exception e) {
-            // If condition evaluation fails, be conservative and assume it 
cannot be satisfied
-            LOGGER.warn("Failed to evaluate condition '{}' on empty document, 
assuming false: {}",
-                    condExpr, e.getMessage());
-            return false;
-        }
-    }
-
     /**
      * Helper class to return query format and whether it needs a VALUES 
document parameter.
      */
diff --git 
a/phoenix-ddb-rest/src/main/java/org/apache/phoenix/ddb/service/utils/DMLUtils.java
 
b/phoenix-ddb-rest/src/main/java/org/apache/phoenix/ddb/service/utils/DMLUtils.java
index f03ca3a..57a8934 100644
--- 
a/phoenix-ddb-rest/src/main/java/org/apache/phoenix/ddb/service/utils/DMLUtils.java
+++ 
b/phoenix-ddb-rest/src/main/java/org/apache/phoenix/ddb/service/utils/DMLUtils.java
@@ -63,7 +63,7 @@ public class DMLUtils {
      * TODO: UPDATED_OLD | UPDATED_NEW
      */
     public static Map<String, Object> executeUpdate(PreparedStatement stmt, 
String returnValue,
-        String returnValuesOnConditionCheckFailure, boolean hasCondExp, 
boolean canEvaluateUpdateExprOnEmptyDoc,
+        String returnValuesOnConditionCheckFailure, boolean hasCondExp, 
boolean canEvaluateExprOnEmptyDoc,
                                                     List<PColumn> pkCols, 
ApiOperation apiOperation)
                             throws SQLException, ConditionCheckFailedException 
{
         try {
@@ -71,12 +71,22 @@ public class DMLUtils {
             if (!needReturnRow(returnValue, 
returnValuesOnConditionCheckFailure)) {
                 int returnStatus = stmt.executeUpdate();
                 if (returnStatus == 0) {
-                    if (hasCondExp) {
-                        throw new ConditionCheckFailedException();
-                    }
-                    if (!canEvaluateUpdateExprOnEmptyDoc && apiOperation == 
ApiOperation.UPDATE_ITEM) {
-                        throw new ValidationException(
-                                "The provided expression references an 
attribute that does not exist in the item");
+                    if (apiOperation == ApiOperation.DELETE_ITEM) {
+                        //there was a condition expression which could not be 
true on empty row
+                        if (hasCondExp && !canEvaluateExprOnEmptyDoc) {
+                            // Condition definitely fails on empty doc → throw
+                            throw new ConditionCheckFailedException();
+                        }
+                        // TODO: If canEvaluateExprOnEmptyDoc is true, we 
can't tell here if:
+                        // TODO: Row didn't exist (success) vs row existed but 
condition failed (should throw)
+                    } else {
+                        if (hasCondExp) {
+                            throw new ConditionCheckFailedException();
+                        }
+                        if (!canEvaluateExprOnEmptyDoc && apiOperation == 
ApiOperation.UPDATE_ITEM) {
+                            throw new ValidationException(
+                                    "The provided expression references an 
attribute that does not exist in the item");
+                        }
                     }
                 }
                 return new HashMap<>();
@@ -95,7 +105,8 @@ public class DMLUtils {
             RawBsonDocument rawBsonDocument =
                 rs == null ? null : (RawBsonDocument) 
rs.getObject(pkCols.size() + 1);
             if ((returnStatus == 0 && apiOperation != 
ApiOperation.DELETE_ITEM) ||
-                (apiOperation == ApiOperation.DELETE_ITEM && rawBsonDocument 
== null)) {
+                (apiOperation == ApiOperation.DELETE_ITEM && rawBsonDocument 
== null
+                        && !canEvaluateExprOnEmptyDoc)) {
                 if (hasCondExp) {
                     ConditionCheckFailedException 
conditionalCheckFailedException =
                         new ConditionCheckFailedException();
@@ -108,7 +119,7 @@ public class DMLUtils {
                     }
                     throw conditionalCheckFailedException;
                 }
-                if (!canEvaluateUpdateExprOnEmptyDoc && apiOperation == 
ApiOperation.UPDATE_ITEM) {
+                if (!canEvaluateExprOnEmptyDoc && apiOperation == 
ApiOperation.UPDATE_ITEM) {
                     throw new ValidationException(
                             "The provided expression references an attribute 
that does not exist in the item");
                 }
@@ -120,7 +131,7 @@ public class DMLUtils {
                         returnValue)) {
                         returnValuesInResponse = true;
                     }
-                } else if (ApiMetadata.ALL_OLD.equals(returnValue)) {
+                } else if (ApiMetadata.ALL_OLD.equals(returnValue) && 
rawBsonDocument != null) {
                     returnValuesInResponse = true;
                 }
                 if (returnValuesInResponse) {
diff --git 
a/phoenix-ddb-rest/src/test/java/org/apache/phoenix/ddb/DeleteItem2IT.java 
b/phoenix-ddb-rest/src/test/java/org/apache/phoenix/ddb/DeleteItem2IT.java
new file mode 100644
index 0000000..154ec99
--- /dev/null
+++ b/phoenix-ddb-rest/src/test/java/org/apache/phoenix/ddb/DeleteItem2IT.java
@@ -0,0 +1,758 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.phoenix.ddb;
+
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.ResultSet;
+import java.util.HashMap;
+import java.util.Map;
+
+import org.junit.AfterClass;
+import org.junit.Assert;
+import org.junit.BeforeClass;
+import org.junit.Ignore;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TestName;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
+import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
+import 
software.amazon.awssdk.services.dynamodb.model.ConditionalCheckFailedException;
+import software.amazon.awssdk.services.dynamodb.model.CreateTableRequest;
+import software.amazon.awssdk.services.dynamodb.model.DeleteItemRequest;
+import software.amazon.awssdk.services.dynamodb.model.DeleteItemResponse;
+import software.amazon.awssdk.services.dynamodb.model.GetItemRequest;
+import software.amazon.awssdk.services.dynamodb.model.GetItemResponse;
+import software.amazon.awssdk.services.dynamodb.model.PutItemRequest;
+import software.amazon.awssdk.services.dynamodb.model.ReturnValue;
+import 
software.amazon.awssdk.services.dynamodb.model.ReturnValuesOnConditionCheckFailure;
+import software.amazon.awssdk.services.dynamodb.model.ScalarAttributeType;
+
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.hbase.HBaseConfiguration;
+import org.apache.hadoop.hbase.HBaseTestingUtility;
+import org.apache.phoenix.ddb.rest.RESTServer;
+import org.apache.phoenix.ddb.utils.PhoenixUtils;
+import org.apache.phoenix.end2end.ServerMetadataCacheTestImpl;
+import org.apache.phoenix.jdbc.PhoenixDriver;
+import org.apache.phoenix.util.PhoenixRuntime;
+import org.apache.phoenix.util.ServerUtil;
+
+import static org.apache.phoenix.query.BaseTest.setUpConfigForMiniCluster;
+
+/**
+ * Integration tests for DeleteItem with various condition expressions.
+ * Tests combinations of:
+ * - Condition expressions that CAN evaluate on empty doc 
(attribute_not_exists)
+ * - Condition expressions that CANNOT evaluate on empty doc 
(attribute_exists, value comparisons)
+ * - Row exists vs row does not exist
+ * - ReturnValues: NONE, ALL_OLD
+ * - ReturnValuesOnConditionCheckFailure: NONE, ALL_OLD
+ * 
+ * All tests verify Phoenix behavior matches LocalDynamoDB behavior.
+ */
+public class DeleteItem2IT {
+
+    private static final Logger LOGGER = 
LoggerFactory.getLogger(DeleteItem2IT.class);
+
+    private final DynamoDbClient dynamoDbClient =
+            LocalDynamoDbTestBase.localDynamoDb().createV2Client();
+    private static DynamoDbClient phoenixDBClientV2;
+
+    private static String url;
+    private static HBaseTestingUtility utility = null;
+    private static String tmpDir;
+    private static RESTServer restServer = null;
+
+    @Rule
+    public final TestName testName = new TestName();
+
+    @BeforeClass
+    public static void initialize() throws Exception {
+        tmpDir = System.getProperty("java.io.tmpdir");
+        LocalDynamoDbTestBase.localDynamoDb().start();
+        Configuration conf = HBaseConfiguration.create();
+        utility = new HBaseTestingUtility(conf);
+        setUpConfigForMiniCluster(conf);
+
+        utility.startMiniCluster();
+        String zkQuorum = "localhost:" + 
utility.getZkCluster().getClientPort();
+        url = PhoenixRuntime.JDBC_PROTOCOL + 
PhoenixRuntime.JDBC_PROTOCOL_SEPARATOR + zkQuorum;
+
+        restServer = new RESTServer(utility.getConfiguration());
+        restServer.run();
+
+        LOGGER.info("started {} on port {}", restServer.getClass().getName(), 
restServer.getPort());
+        phoenixDBClientV2 = LocalDynamoDB.createV2Client("http://"; + 
restServer.getServerAddress());
+    }
+
+    @AfterClass
+    public static void stopLocalDynamoDb() throws Exception {
+        LocalDynamoDbTestBase.localDynamoDb().stop();
+        if (restServer != null) {
+            restServer.stop();
+        }
+        ServerUtil.ConnectionFactory.shutdown();
+        try {
+            DriverManager.deregisterDriver(PhoenixDriver.INSTANCE);
+        } finally {
+            if (utility != null) {
+                utility.shutdownMiniCluster();
+            }
+            ServerMetadataCacheTestImpl.resetCache();
+        }
+        System.setProperty("java.io.tmpdir", tmpDir);
+    }
+
+    // ==================== attribute_not_exists (CAN evaluate on empty doc) 
====================
+
+    /**
+     * Test: attribute_not_exists condition, row EXISTS, ReturnValues=ALL_OLD
+     * Expected: Condition fails (attribute exists), 
ConditionalCheckFailedException thrown
+     */
+    @Ignore
+    public void testAttributeNotExists_RowExists_ReturnAllOld() {
+        final String tableName = testName.getMethodName();
+        createTableAndPutItem(tableName);
+
+        Map<String, AttributeValue> key = createKey();
+        Map<String, String> exprAttrNames = new HashMap<>();
+        exprAttrNames.put("#attr", "ForumName");
+        
+        DeleteItemRequest request = DeleteItemRequest.builder()
+                .tableName(tableName)
+                .key(key)
+                .conditionExpression("attribute_not_exists(#attr)")
+                .expressionAttributeNames(exprAttrNames)
+                .returnValues(ReturnValue.ALL_OLD)
+                .build();
+
+        // Both should throw ConditionalCheckFailedException
+        ConditionalCheckFailedException dynamoException = null;
+        ConditionalCheckFailedException phoenixException = null;
+
+        try {
+            dynamoDbClient.deleteItem(request);
+            Assert.fail("DynamoDB should throw 
ConditionalCheckFailedException");
+        } catch (ConditionalCheckFailedException e) {
+            dynamoException = e;
+        }
+
+        try {
+            phoenixDBClientV2.deleteItem(request);
+            Assert.fail("Phoenix should throw 
ConditionalCheckFailedException");
+        } catch (ConditionalCheckFailedException e) {
+            phoenixException = e;
+        }
+
+        // Verify exceptions match (item should be null since 
ReturnValuesOnConditionCheckFailure not set)
+        Assert.assertEquals("Exception item should match", 
dynamoException.item(), phoenixException.item());
+
+        // Verify row still exists
+        verifyRow(tableName, key);
+    }
+
+    /**
+     * Test: attribute_not_exists condition, row EXISTS, 
ReturnValuesOnConditionCheckFailure=ALL_OLD
+     * Expected: Condition fails, exception contains the old item
+     */
+    @Ignore
+    public void 
testAttributeNotExists_RowExists_ReturnValuesOnConditionCheckFailure() {
+        final String tableName = testName.getMethodName();
+        createTableAndPutItem(tableName);
+
+        Map<String, AttributeValue> key = createKey();
+        Map<String, String> exprAttrNames = new HashMap<>();
+        exprAttrNames.put("#attr", "ForumName");
+        
+        DeleteItemRequest request = DeleteItemRequest.builder()
+                .tableName(tableName)
+                .key(key)
+                .conditionExpression("attribute_not_exists(#attr)")
+                .expressionAttributeNames(exprAttrNames)
+                
.returnValuesOnConditionCheckFailure(ReturnValuesOnConditionCheckFailure.ALL_OLD)
+                .build();
+
+        ConditionalCheckFailedException dynamoException = null;
+        ConditionalCheckFailedException phoenixException = null;
+
+        try {
+            dynamoDbClient.deleteItem(request);
+            Assert.fail("DynamoDB should throw 
ConditionalCheckFailedException");
+        } catch (ConditionalCheckFailedException e) {
+            dynamoException = e;
+        }
+
+        try {
+            phoenixDBClientV2.deleteItem(request);
+            Assert.fail("Phoenix should throw 
ConditionalCheckFailedException");
+        } catch (ConditionalCheckFailedException e) {
+            phoenixException = e;
+        }
+
+        // Both should return the old item in the exception
+        Assert.assertNotNull("DynamoDB exception should contain item", 
dynamoException.item());
+        Assert.assertEquals("Exception items should match", 
dynamoException.item(), phoenixException.item());
+
+        // Verify row still exists
+        verifyRow(tableName, key);
+    }
+
+    /**
+     * Test: attribute_not_exists condition on non-key attribute, row EXISTS 
but attribute does NOT exist
+     * Expected: Condition succeeds, row deleted, ALL_OLD returns the deleted 
item
+     */
+    @Test(timeout = 120000)
+    public void 
testAttributeNotExists_RowExists_AttributeMissing_ReturnAllOld() {
+        final String tableName = testName.getMethodName();
+        createTableAndPutItem(tableName);
+
+        Map<String, AttributeValue> key = createKey();
+        Map<String, String> exprAttrNames = new HashMap<>();
+        exprAttrNames.put("#attr", "NonExistentAttribute");
+        
+        DeleteItemRequest request = DeleteItemRequest.builder()
+                .tableName(tableName)
+                .key(key)
+                .conditionExpression("attribute_not_exists(#attr)")
+                .expressionAttributeNames(exprAttrNames)
+                .returnValues(ReturnValue.ALL_OLD)
+                .build();
+
+        DeleteItemResponse dynamoResponse = dynamoDbClient.deleteItem(request);
+        DeleteItemResponse phoenixResponse = 
phoenixDBClientV2.deleteItem(request);
+
+        // Both should succeed and return the old item
+        Assert.assertNotNull("DynamoDB should return attributes", 
dynamoResponse.attributes());
+        Assert.assertEquals("Attributes should match", 
dynamoResponse.attributes(), phoenixResponse.attributes());
+
+        // Verify row is deleted
+        verifyRow(tableName, key);
+    }
+
+    /**
+     * Test: attribute_not_exists condition, row does NOT exist, 
ReturnValues=ALL_OLD
+     * Expected: Condition succeeds (attribute doesn't exist because row 
doesn't exist), 
+     *           delete is a no-op, ALL_OLD returns null/empty
+     */
+    @Test(timeout = 120000)
+    public void testAttributeNotExists_RowNotExists_ReturnAllOld() {
+        final String tableName = testName.getMethodName();
+        createTableOnly(tableName);
+
+        Map<String, AttributeValue> key = createKey();
+        Map<String, String> exprAttrNames = new HashMap<>();
+        exprAttrNames.put("#attr", "ForumName");
+        
+        DeleteItemRequest request = DeleteItemRequest.builder()
+                .tableName(tableName)
+                .key(key)
+                .conditionExpression("attribute_not_exists(#attr)")
+                .expressionAttributeNames(exprAttrNames)
+                .returnValues(ReturnValue.ALL_OLD)
+                .build();
+
+        DeleteItemResponse dynamoResponse = dynamoDbClient.deleteItem(request);
+        DeleteItemResponse phoenixResponse = 
phoenixDBClientV2.deleteItem(request);
+
+        // Both should succeed with no attributes returned (row didn't exist)
+        LOGGER.info("DynamoDB response attributes: {}", 
dynamoResponse.attributes());
+        LOGGER.info("Phoenix response attributes: {}", 
phoenixResponse.attributes());
+        Assert.assertEquals("Attributes should match (both empty/null)", 
+                dynamoResponse.attributes(), phoenixResponse.attributes());
+        Assert.assertTrue("Attributes should be empty or null", 
+                dynamoResponse.attributes() == null || 
dynamoResponse.attributes().isEmpty());
+
+        // Verify row still doesn't exist
+        verifyRow(tableName, key);
+    }
+
+    /**
+     * Test: attribute_not_exists condition, row does NOT exist, no 
ReturnValues
+     * Expected: Condition succeeds, delete is a no-op
+     */
+    @Test(timeout = 120000)
+    public void testAttributeNotExists_RowNotExists_NoReturnValues() {
+        final String tableName = testName.getMethodName();
+        createTableOnly(tableName);
+
+        Map<String, AttributeValue> key = createKey();
+        Map<String, String> exprAttrNames = new HashMap<>();
+        exprAttrNames.put("#attr", "ForumName");
+        
+        DeleteItemRequest request = DeleteItemRequest.builder()
+                .tableName(tableName)
+                .key(key)
+                .conditionExpression("attribute_not_exists(#attr)")
+                .expressionAttributeNames(exprAttrNames)
+                .build();
+
+        DeleteItemResponse dynamoResponse = dynamoDbClient.deleteItem(request);
+        DeleteItemResponse phoenixResponse = 
phoenixDBClientV2.deleteItem(request);
+
+        // Both should succeed
+        Assert.assertEquals("Attributes should match", 
+                dynamoResponse.attributes(), phoenixResponse.attributes());
+
+        // Verify row still doesn't exist
+        verifyRow(tableName, key);
+    }
+
+    // ==================== attribute_exists (CANNOT evaluate on empty doc) 
====================
+
+    /**
+     * Test: attribute_exists condition, row EXISTS, ReturnValues=ALL_OLD
+     * Expected: Condition succeeds, row deleted, ALL_OLD returns the deleted 
item
+     */
+    @Test(timeout = 120000)
+    public void testAttributeExists_RowExists_ReturnAllOld() {
+        final String tableName = testName.getMethodName();
+        createTableAndPutItem(tableName);
+
+        Map<String, AttributeValue> key = createKey();
+        Map<String, String> exprAttrNames = new HashMap<>();
+        exprAttrNames.put("#attr", "ForumName");
+        
+        DeleteItemRequest request = DeleteItemRequest.builder()
+                .tableName(tableName)
+                .key(key)
+                .conditionExpression("attribute_exists(#attr)")
+                .expressionAttributeNames(exprAttrNames)
+                .returnValues(ReturnValue.ALL_OLD)
+                .build();
+
+        DeleteItemResponse dynamoResponse = dynamoDbClient.deleteItem(request);
+        DeleteItemResponse phoenixResponse = 
phoenixDBClientV2.deleteItem(request);
+
+        // Both should succeed and return the old item
+        Assert.assertNotNull("DynamoDB should return attributes", 
dynamoResponse.attributes());
+        Assert.assertEquals("Attributes should match", 
dynamoResponse.attributes(), phoenixResponse.attributes());
+
+        // Verify row is deleted
+        verifyRow(tableName, key);
+    }
+
+    /**
+     * Test: attribute_exists condition, row EXISTS but attribute does NOT 
exist
+     * Expected: Condition fails, ConditionalCheckFailedException thrown
+     */
+    @Test(timeout = 120000)
+    public void testAttributeExists_RowExists_AttributeMissing_ReturnAllOld() {
+        final String tableName = testName.getMethodName();
+        createTableAndPutItem(tableName);
+
+        Map<String, AttributeValue> key = createKey();
+        Map<String, String> exprAttrNames = new HashMap<>();
+        exprAttrNames.put("#attr", "NonExistentAttribute");
+        
+        DeleteItemRequest request = DeleteItemRequest.builder()
+                .tableName(tableName)
+                .key(key)
+                .conditionExpression("attribute_exists(#attr)")
+                .expressionAttributeNames(exprAttrNames)
+                .returnValues(ReturnValue.ALL_OLD)
+                
.returnValuesOnConditionCheckFailure(ReturnValuesOnConditionCheckFailure.ALL_OLD)
+                .build();
+
+        ConditionalCheckFailedException dynamoException = null;
+        ConditionalCheckFailedException phoenixException = null;
+
+        try {
+            dynamoDbClient.deleteItem(request);
+            Assert.fail("DynamoDB should throw 
ConditionalCheckFailedException");
+        } catch (ConditionalCheckFailedException e) {
+            dynamoException = e;
+        }
+
+        try {
+            phoenixDBClientV2.deleteItem(request);
+            Assert.fail("Phoenix should throw 
ConditionalCheckFailedException");
+        } catch (ConditionalCheckFailedException e) {
+            phoenixException = e;
+        }
+
+        Assert.assertEquals("Exception items should match", 
dynamoException.item(), phoenixException.item());
+
+        // Verify row still exists
+        verifyRow(tableName, key);
+    }
+
+    /**
+     * Test: attribute_exists condition, row does NOT exist, 
ReturnValues=ALL_OLD
+     * Expected: Condition fails, ConditionalCheckFailedException thrown
+     */
+    @Test(timeout = 120000)
+    public void testAttributeExists_RowNotExists_ReturnAllOld() {
+        final String tableName = testName.getMethodName();
+        createTableOnly(tableName);
+
+        Map<String, AttributeValue> key = createKey();
+        Map<String, String> exprAttrNames = new HashMap<>();
+        exprAttrNames.put("#attr", "ForumName");
+        
+        DeleteItemRequest request = DeleteItemRequest.builder()
+                .tableName(tableName)
+                .key(key)
+                .conditionExpression("attribute_exists(#attr)")
+                .expressionAttributeNames(exprAttrNames)
+                .returnValues(ReturnValue.ALL_OLD)
+                
.returnValuesOnConditionCheckFailure(ReturnValuesOnConditionCheckFailure.ALL_OLD)
+                .build();
+
+        ConditionalCheckFailedException dynamoException = null;
+        ConditionalCheckFailedException phoenixException = null;
+
+        try {
+            dynamoDbClient.deleteItem(request);
+            Assert.fail("DynamoDB should throw 
ConditionalCheckFailedException");
+        } catch (ConditionalCheckFailedException e) {
+            dynamoException = e;
+        }
+
+        try {
+            phoenixDBClientV2.deleteItem(request);
+            Assert.fail("Phoenix should throw 
ConditionalCheckFailedException");
+        } catch (ConditionalCheckFailedException e) {
+            phoenixException = e;
+        }
+
+        // Both should have null/empty item (row didn't exist)
+        LOGGER.info("DynamoDB exception item: {}", dynamoException.item());
+        LOGGER.info("Phoenix exception item: {}", phoenixException.item());
+        Assert.assertEquals("Exception items should match", 
dynamoException.item(), phoenixException.item());
+
+        // Verify row still doesn't exist
+        verifyRow(tableName, key);
+    }
+
+    // ==================== Value comparison (CANNOT evaluate on empty doc) 
====================
+
+    /**
+     * Test: Value comparison condition succeeds, row EXISTS, 
ReturnValues=ALL_OLD
+     * Expected: Condition succeeds, row deleted, ALL_OLD returns the deleted 
item
+     */
+    @Test(timeout = 120000)
+    public void testValueComparison_RowExists_ConditionSucceeds_ReturnAllOld() 
{
+        final String tableName = testName.getMethodName();
+        createTableAndPutItem(tableName);
+
+        Map<String, AttributeValue> key = createKey();
+        Map<String, String> exprAttrNames = new HashMap<>();
+        exprAttrNames.put("#attr", "Status");
+        Map<String, AttributeValue> exprAttrValues = new HashMap<>();
+        exprAttrValues.put(":val", 
AttributeValue.builder().s("active").build());
+        
+        DeleteItemRequest request = DeleteItemRequest.builder()
+                .tableName(tableName)
+                .key(key)
+                .conditionExpression("#attr = :val")
+                .expressionAttributeNames(exprAttrNames)
+                .expressionAttributeValues(exprAttrValues)
+                .returnValues(ReturnValue.ALL_OLD)
+                .build();
+
+        DeleteItemResponse dynamoResponse = dynamoDbClient.deleteItem(request);
+        DeleteItemResponse phoenixResponse = 
phoenixDBClientV2.deleteItem(request);
+
+        // Both should succeed and return the old item
+        Assert.assertNotNull("DynamoDB should return attributes", 
dynamoResponse.attributes());
+        Assert.assertEquals("Attributes should match", 
dynamoResponse.attributes(), phoenixResponse.attributes());
+
+        // Verify row is deleted
+        verifyRow(tableName, key);
+    }
+
+    /**
+     * Test: Value comparison condition fails, row EXISTS, 
ReturnValuesOnConditionCheckFailure=ALL_OLD
+     * Expected: Condition fails, exception contains the old item
+     */
+    @Test(timeout = 120000)
+    public void 
testValueComparison_RowExists_ConditionFails_ReturnValuesOnConditionCheckFailure()
 {
+        final String tableName = testName.getMethodName();
+        createTableAndPutItem(tableName);
+
+        Map<String, AttributeValue> key = createKey();
+        Map<String, String> exprAttrNames = new HashMap<>();
+        exprAttrNames.put("#attr", "Status");
+        Map<String, AttributeValue> exprAttrValues = new HashMap<>();
+        exprAttrValues.put(":val", 
AttributeValue.builder().s("inactive").build());
+        
+        DeleteItemRequest request = DeleteItemRequest.builder()
+                .tableName(tableName)
+                .key(key)
+                .conditionExpression("#attr = :val")
+                .expressionAttributeNames(exprAttrNames)
+                .expressionAttributeValues(exprAttrValues)
+                
.returnValuesOnConditionCheckFailure(ReturnValuesOnConditionCheckFailure.ALL_OLD)
+                .build();
+
+        ConditionalCheckFailedException dynamoException = null;
+        ConditionalCheckFailedException phoenixException = null;
+
+        try {
+            dynamoDbClient.deleteItem(request);
+            Assert.fail("DynamoDB should throw 
ConditionalCheckFailedException");
+        } catch (ConditionalCheckFailedException e) {
+            dynamoException = e;
+        }
+
+        try {
+            phoenixDBClientV2.deleteItem(request);
+            Assert.fail("Phoenix should throw 
ConditionalCheckFailedException");
+        } catch (ConditionalCheckFailedException e) {
+            phoenixException = e;
+        }
+
+        // Both should return the old item in the exception
+        Assert.assertNotNull("DynamoDB exception should contain item", 
dynamoException.item());
+        Assert.assertEquals("Exception items should match", 
dynamoException.item(), phoenixException.item());
+
+        // Verify row still exists
+        verifyRow(tableName, key);
+    }
+
+    /**
+     * Test: Value comparison condition, row does NOT exist
+     * Expected: Condition fails, ConditionalCheckFailedException thrown
+     */
+    @Test(timeout = 120000)
+    public void testValueComparison_RowNotExists() {
+        final String tableName = testName.getMethodName();
+        createTableOnly(tableName);
+
+        Map<String, AttributeValue> key = createKey();
+        Map<String, String> exprAttrNames = new HashMap<>();
+        exprAttrNames.put("#attr", "Status");
+        Map<String, AttributeValue> exprAttrValues = new HashMap<>();
+        exprAttrValues.put(":val", 
AttributeValue.builder().s("active").build());
+        
+        DeleteItemRequest request = DeleteItemRequest.builder()
+                .tableName(tableName)
+                .key(key)
+                .conditionExpression("#attr = :val")
+                .expressionAttributeNames(exprAttrNames)
+                .expressionAttributeValues(exprAttrValues)
+                
.returnValuesOnConditionCheckFailure(ReturnValuesOnConditionCheckFailure.ALL_OLD)
+                .build();
+
+        ConditionalCheckFailedException dynamoException = null;
+        ConditionalCheckFailedException phoenixException = null;
+
+        try {
+            dynamoDbClient.deleteItem(request);
+            Assert.fail("DynamoDB should throw 
ConditionalCheckFailedException");
+        } catch (ConditionalCheckFailedException e) {
+            dynamoException = e;
+        }
+
+        try {
+            phoenixDBClientV2.deleteItem(request);
+            Assert.fail("Phoenix should throw 
ConditionalCheckFailedException");
+        } catch (ConditionalCheckFailedException e) {
+            phoenixException = e;
+        }
+
+        // Both should have null/empty item (row didn't exist)
+        Assert.assertEquals("Exception items should match", 
dynamoException.item(), phoenixException.item());
+
+        // Verify row still doesn't exist
+        verifyRow(tableName, key);
+    }
+
+    // ==================== Combined conditions ====================
+
+    /**
+     * Test: Combined condition (attribute_not_exists OR value=X), row does 
NOT exist
+     * Expected: Condition succeeds (attribute_not_exists is true), delete is 
a no-op
+     */
+    @Test(timeout = 120000)
+    public void testCombinedCondition_AttributeNotExistsOrValue_RowNotExists() 
{
+        final String tableName = testName.getMethodName();
+        createTableOnly(tableName);
+
+        Map<String, AttributeValue> key = createKey();
+        Map<String, String> exprAttrNames = new HashMap<>();
+        exprAttrNames.put("#attr", "ForumName");
+        exprAttrNames.put("#status", "Status");
+        Map<String, AttributeValue> exprAttrValues = new HashMap<>();
+        exprAttrValues.put(":val", 
AttributeValue.builder().s("active").build());
+        
+        DeleteItemRequest request = DeleteItemRequest.builder()
+                .tableName(tableName)
+                .key(key)
+                .conditionExpression("attribute_not_exists(#attr) OR #status = 
:val")
+                .expressionAttributeNames(exprAttrNames)
+                .expressionAttributeValues(exprAttrValues)
+                .returnValues(ReturnValue.ALL_OLD)
+                .build();
+
+        DeleteItemResponse dynamoResponse = dynamoDbClient.deleteItem(request);
+        DeleteItemResponse phoenixResponse = 
phoenixDBClientV2.deleteItem(request);
+
+        // Both should succeed with no attributes (row didn't exist)
+        Assert.assertEquals("Attributes should match", 
+                dynamoResponse.attributes(), phoenixResponse.attributes());
+
+        // Verify row still doesn't exist
+        verifyRow(tableName, key);
+    }
+
+    /**
+     * Test: Combined condition (attribute_exists AND value=X), row EXISTS, 
condition succeeds
+     * Expected: Condition succeeds, row deleted
+     */
+    @Test(timeout = 120000)
+    public void 
testCombinedCondition_AttributeExistsAndValue_RowExists_ConditionSucceeds() {
+        final String tableName = testName.getMethodName();
+        createTableAndPutItem(tableName);
+
+        Map<String, AttributeValue> key = createKey();
+        Map<String, String> exprAttrNames = new HashMap<>();
+        exprAttrNames.put("#attr", "ForumName");
+        exprAttrNames.put("#status", "Status");
+        Map<String, AttributeValue> exprAttrValues = new HashMap<>();
+        exprAttrValues.put(":val", 
AttributeValue.builder().s("active").build());
+        
+        DeleteItemRequest request = DeleteItemRequest.builder()
+                .tableName(tableName)
+                .key(key)
+                .conditionExpression("attribute_exists(#attr) AND #status = 
:val")
+                .expressionAttributeNames(exprAttrNames)
+                .expressionAttributeValues(exprAttrValues)
+                .returnValues(ReturnValue.ALL_OLD)
+                .build();
+
+        DeleteItemResponse dynamoResponse = dynamoDbClient.deleteItem(request);
+        DeleteItemResponse phoenixResponse = 
phoenixDBClientV2.deleteItem(request);
+
+        // Both should succeed and return the old item
+        Assert.assertNotNull("DynamoDB should return attributes", 
dynamoResponse.attributes());
+        Assert.assertEquals("Attributes should match", 
dynamoResponse.attributes(), phoenixResponse.attributes());
+
+        // Verify row is deleted
+        verifyRow(tableName, key);
+    }
+
+    // ==================== No condition expression ====================
+
+    /**
+     * Test: No condition, row EXISTS, ReturnValues=ALL_OLD
+     * Expected: Delete succeeds, ALL_OLD returns the deleted item
+     */
+    @Test(timeout = 120000)
+    public void testNoCondition_RowExists_ReturnAllOld() {
+        final String tableName = testName.getMethodName();
+        createTableAndPutItem(tableName);
+
+        Map<String, AttributeValue> key = createKey();
+        DeleteItemRequest request = DeleteItemRequest.builder()
+                .tableName(tableName)
+                .key(key)
+                .returnValues(ReturnValue.ALL_OLD)
+                .build();
+
+        DeleteItemResponse dynamoResponse = dynamoDbClient.deleteItem(request);
+        DeleteItemResponse phoenixResponse = 
phoenixDBClientV2.deleteItem(request);
+
+        // Both should succeed and return the old item
+        Assert.assertNotNull("DynamoDB should return attributes", 
dynamoResponse.attributes());
+        Assert.assertEquals("Attributes should match", 
dynamoResponse.attributes(), phoenixResponse.attributes());
+
+        // Verify row is deleted
+        verifyRow(tableName, key);
+    }
+
+    /**
+     * Test: No condition, row does NOT exist, ReturnValues=ALL_OLD
+     * Expected: Delete is a no-op, ALL_OLD returns null/empty
+     */
+    @Test(timeout = 120000)
+    public void testNoCondition_RowNotExists_ReturnAllOld() {
+        final String tableName = testName.getMethodName();
+        createTableOnly(tableName);
+
+        Map<String, AttributeValue> key = createKey();
+        DeleteItemRequest request = DeleteItemRequest.builder()
+                .tableName(tableName)
+                .key(key)
+                .returnValues(ReturnValue.ALL_OLD)
+                .build();
+
+        DeleteItemResponse dynamoResponse = dynamoDbClient.deleteItem(request);
+        DeleteItemResponse phoenixResponse = 
phoenixDBClientV2.deleteItem(request);
+
+        // Both should succeed with no attributes (row didn't exist)
+        Assert.assertEquals("Attributes should match", 
+                dynamoResponse.attributes(), phoenixResponse.attributes());
+        Assert.assertTrue("Attributes should be empty or null", 
+                dynamoResponse.attributes() == null || 
dynamoResponse.attributes().isEmpty());
+
+        // Verify row still doesn't exist
+        verifyRow(tableName, key);
+    }
+
+    // ==================== Helper methods ====================
+
+    private void createTableOnly(String tableName) {
+        CreateTableRequest createTableRequest =
+                DDLTestUtils.getCreateTableRequest(tableName, "pk", 
ScalarAttributeType.S,
+                        "sk", ScalarAttributeType.S);
+        phoenixDBClientV2.createTable(createTableRequest);
+        dynamoDbClient.createTable(createTableRequest);
+    }
+
+    private void createTableAndPutItem(String tableName) {
+        createTableOnly(tableName);
+        PutItemRequest putItemRequest =
+                
PutItemRequest.builder().tableName(tableName).item(createItem()).build();
+        phoenixDBClientV2.putItem(putItemRequest);
+        dynamoDbClient.putItem(putItemRequest);
+    }
+
+    private Map<String, AttributeValue> createKey() {
+        Map<String, AttributeValue> key = new HashMap<>();
+        key.put("pk", AttributeValue.builder().s("partition1").build());
+        key.put("sk", AttributeValue.builder().s("sort1").build());
+        return key;
+    }
+
+    private Map<String, AttributeValue> createItem() {
+        Map<String, AttributeValue> item = new HashMap<>();
+        item.put("pk", AttributeValue.builder().s("partition1").build());
+        item.put("sk", AttributeValue.builder().s("sort1").build());
+        item.put("ForumName", AttributeValue.builder().s("Amazon 
DynamoDB").build());
+        item.put("Status", AttributeValue.builder().s("active").build());
+        item.put("Count", AttributeValue.builder().n("42").build());
+        item.put("Tags", AttributeValue.builder().ss("tag1", "tag2").build());
+        return item;
+    }
+
+    private void verifyRow(String tableName, Map<String, AttributeValue> key) {
+        GetItemRequest getRequest = GetItemRequest.builder()
+                .tableName(tableName)
+                .key(key)
+                .build();
+
+        GetItemResponse dynamoResponse = dynamoDbClient.getItem(getRequest);
+        GetItemResponse phoenixResponse = 
phoenixDBClientV2.getItem(getRequest);
+        Assert.assertEquals("GetItem responses should match", 
+                dynamoResponse.item(), phoenixResponse.item());
+    }
+}
diff --git 
a/phoenix-ddb-utils/src/main/java/org/apache/phoenix/ddb/utils/CommonServiceUtils.java
 
b/phoenix-ddb-utils/src/main/java/org/apache/phoenix/ddb/utils/CommonServiceUtils.java
index f7f7684..7c3cfd0 100644
--- 
a/phoenix-ddb-utils/src/main/java/org/apache/phoenix/ddb/utils/CommonServiceUtils.java
+++ 
b/phoenix-ddb-utils/src/main/java/org/apache/phoenix/ddb/utils/CommonServiceUtils.java
@@ -18,6 +18,10 @@
 
 package org.apache.phoenix.ddb.utils;
 
+import org.apache.phoenix.expression.util.bson.SQLComparisonExpressionUtils;
+import org.bson.RawBsonDocument;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 import software.amazon.awssdk.services.dynamodb.model.ScalarAttributeType;
 
 import org.apache.commons.lang3.StringUtils;
@@ -49,6 +53,9 @@ public class CommonServiceUtils {
     public static final String DOUBLE_QUOTE = "\"";
     public static final String HASH = "#";
     private static final Pattern EXPR_ATTR_NAME_PATTERN = 
Pattern.compile("#([a-zA-Z0-9_]+)");
+    private static final BsonDocument EMPTY_BSON_DOC = new BsonDocument();
+    private static final RawBsonDocument EMPTY_RAW_BSON_DOC = 
RawBsonDocument.parse("{}");
+    private static final Logger LOGGER = 
LoggerFactory.getLogger(CommonServiceUtils.class);
 
     public static boolean isCauseMessageAvailable(Exception e) {
         return e.getCause() != null && e.getCause().getMessage() != null;
@@ -549,4 +556,42 @@ public class CommonServiceUtils {
         }
         return String.join(", ", attributesToGet);
     }
+
+    /**
+     * Evaluate if a condition expression can be satisfied on a non-existing 
item.
+     * Returns true if the condition PASSES when evaluated against an empty 
document.
+     * <p>
+     * <b>Usage by UpdateItemService:</b>
+     * Determines whether UPDATE (allows creation) or UPDATE_ONLY (existing 
only) query should be used.
+     * If condition passes on empty doc, the item can be created if it doesn't 
exist.
+     * <p>
+     * <b>Usage by DeleteItemService:</b>
+     * Determines behavior when DELETE returns no row. If condition passes on 
empty doc,
+     * a missing row is valid (no-op). If condition fails on empty doc, throw
+     * ConditionalCheckFailedException.
+     * TODO: When condition passes on empty doc but no row was
+     * TODO: returned, DeleteItemService must distinguish between
+     * TODO: "row didn't exist" (success) vs "row existed but condition 
failed" (throw exception).
+     *
+     * @param condExpr the condition expression string
+     * @param exprAttrNames expression attribute names map
+     * @return true if condition passes on empty document, false otherwise
+     */
+    public static boolean evaluateConditionOnNonExistingItem(String condExpr,
+                                                              Map<String, 
String> exprAttrNames) {
+        try {
+            BsonDocument exprAttrNamesDoc =
+                    
CommonServiceUtils.getExpressionAttributeNamesDoc(exprAttrNames);
+            boolean result = 
SQLComparisonExpressionUtils.evaluateConditionExpression(condExpr,
+                    EMPTY_RAW_BSON_DOC, EMPTY_BSON_DOC, exprAttrNamesDoc);
+
+            LOGGER.debug("Condition '{}' evaluation on empty document: {}", 
condExpr, result);
+            return result;
+        } catch (Exception e) {
+            // If condition evaluation fails, be conservative and assume it 
cannot be satisfied
+            LOGGER.warn("Failed to evaluate condition '{}' on empty document, 
assuming false: {}",
+                    condExpr, e.getMessage());
+            return false;
+        }
+    }
 }


Reply via email to