Repository: floci-io/floci (2026-04-09T10:31:47Z)

Issue:
[BUG] DynamoDB Enhanced Client initial null Boolean field does not get updated on update_item
## Service

DynamoDB

## AWS API Action

put_item() followed by an update_item() using Spring Boot 4.0.5 and AWS SDK version 2.42.30 and Kotlin 2.3.20

## Expected behavior

When putting an item with put_item in DDB with an optional Boolean field, and then setting the boolean field to true and using update_item to have the row being updated with boolean value true.
 

## Actual behavior

When retrieving the item after above scenario, the boolean field value is still null.

## Reproduction

Python example that mimics what enhanced client does:
```
import boto3
from botocore.config import Config

# Configure boto3 to use Floci
endpoint_url = "http://localhost:4566"
region = "us-east-1"

dynamodb = boto3.client(
    'dynamodb',
    endpoint_url=endpoint_url,
    region_name=region,
    aws_access_key_id='test',
    aws_secret_access_key='test',
    config=Config(signature_version='v4')
)

TABLE_NAME = "test-boolean-fix"


def setup_table():
    """Create the test table."""
    try:
        dynamodb.delete_table(TableName=TABLE_NAME)
        print(f"Deleted existing table: {TABLE_NAME}")
    except dynamodb.exceptions.ResourceNotFoundException:
        pass

    dynamodb.create_table(
        TableName=TABLE_NAME,
        KeySchema=[
            {'AttributeName': 'userId', 'KeyType': 'HASH'}
        ],
        AttributeDefinitions=[
            {'AttributeName': 'userId', 'AttributeType': 'S'}
        ],
        ProvisionedThroughput={
            'ReadCapacityUnits': 5,
            'WriteCapacityUnits': 5
        }
    )
    print(f"Created table: {TABLE_NAME}")


def test_boolean_update_with_set_and_remove():
    """
    Test that demonstrates the fix for boolean updates with SET and REMOVE combined.
    Which the Java SDK v2 enhanced client does underwater. There's no boto3 equivalent of that enhanced client.

    This mimics what the DynamoDB Enhanced Client does when ignoreNulls=false:
    - SET non-null fields
    - REMOVE null fields

    The bug was: the last SET attribute before REMOVE was not being set correctly
    because the parser included part of the REMOVE clause in the value lookup.
    """
    print("\n" + "="*60)
    print("Test: Boolean update with SET and REMOVE combined")
    print("="*60)

    user_id = "user-123"

    # Step 1: Create item WITHOUT the boolean field
    print("\n1. Creating item WITHOUT isActive field...")
    dynamodb.put_item(
        TableName=TABLE_NAME,
        Item={
            'userId': {'S': user_id},
            'entries': {'S': 'initial entries'},
            'created': {'N': '1234567890'},
            'tempField': {'S': 'to be removed'}
        }
    )

    # Verify initial state
    response = dynamodb.get_item(
        TableName=TABLE_NAME,
        Key={'userId': {'S': user_id}}
    )
    item = response.get('Item', {})
    print(f"   Initial item: {item}")
    assert 'isActive' not in item, "isActive should not exist initially"
    assert 'tempField' in item, "tempField should exist initially"
    print("   ✓ Initial state verified")

    # Step 2: Update with SET and REMOVE combined (like Enhanced Client does)
    print("\n2. Updating with SET and REMOVE combined...")
    print("   UpdateExpression: SET #entries = :entries, #isActive = :isActive REMOVE #tempField, #created")

    update_response = dynamodb.update_item(
        TableName=TABLE_NAME,
        Key={'userId': {'S': user_id}},
        UpdateExpression="SET #entries = :entries, #isActive = :isActive REMOVE #tempField, #created",
        ExpressionAttributeNames={
            '#entries': 'entries',
            '#isActive': 'isActive',
            '#tempField': 'tempField',
            '#created': 'created'
        },
        ExpressionAttributeValues={
            ':entries': {'S': 'updated entries'},
            ':isActive': {'BOOL': True}
        },
        ReturnValues='ALL_NEW'
    )

    updated_item = update_response.get('Attributes', {})
    print(f"   Updated item: {updated_item}")

    # Verify the update response
    assert 'isActive' in updated_item, "isActive should exist after update"
    assert updated_item['isActive'].get('BOOL') == True, "isActive should be True"
    assert updated_item['entries']['S'] == 'updated entries', "entries should be updated"
    assert 'tempField' not in updated_item, "tempField should be removed"
    assert 'created' not in updated_item, "created should be removed"
    print("   ✓ Update response verified")

    # Step 3: Get the item to verify persistence
    print("\n3. Getting item to verify persistence...")
    response = dynamodb.get_item(
        TableName=TABLE_NAME,
        Key={'userId': {'S': user_id}}
    )
    final_item = response.get('Item', {})
    print(f"   Final item: {final_item}")

    assert final_item['isActive'].get('BOOL') == True, "isActive should still be True after get"
    print("   ✓ Persistence verified")

    print("\n" + "="*60)
    print("✓ TEST PASSED: Boolean update with SET and REMOVE works correctly!")
    print("="*60)


def test_boolean_update_from_false_to_true():
    """
    Test updating a boolean from false to true with SET and REMOVE.
    """
    print("\n" + "="*60)
    print("Test: Boolean update from false to true")
    print("="*60)

    user_id = "user-456"

    # Step 1: Create item WITH isActive = false
    print("\n1. Creating item with isActive = false...")
    dynamodb.put_item(
        TableName=TABLE_NAME,
        Item={
            'userId': {'S': user_id},
            'entries': {'S': 'initial'},
            'isActive': {'BOOL': False},
            'tempField': {'S': 'temp'}
        }
    )

    response = dynamodb.get_item(
        TableName=TABLE_NAME,
        Key={'userId': {'S': user_id}}
    )
    item = response.get('Item', {})
    assert item['isActive'].get('BOOL') == False, "isActive should be False initially"
    print("   ✓ Initial state: isActive = False")

    # Step 2: Update isActive to true with REMOVE clause
    print("\n2. Updating isActive to true with REMOVE clause...")
    update_response = dynamodb.update_item(
        TableName=TABLE_NAME,
        Key={'userId': {'S': user_id}},
        UpdateExpression="SET #entries = :entries, #isActive = :isActive REMOVE #tempField",
        ExpressionAttributeNames={
            '#entries': 'entries',
            '#isActive': 'isActive',
            '#tempField': 'tempField'
        },
        ExpressionAttributeValues={
            ':entries': {'S': 'updated'},
            ':isActive': {'BOOL': True}
        },
        ReturnValues='ALL_NEW'
    )

    updated_item = update_response.get('Attributes', {})
    assert updated_item['isActive'].get('BOOL') == True, "isActive should be True after update"
    print("   ✓ Update response: isActive = True")

    # Step 3: Verify persistence
    print("\n3. Verifying persistence...")
    response = dynamodb.get_item(
        TableName=TABLE_NAME,
        Key={'userId': {'S': user_id}}
    )
    final_item = response.get('Item', {})
    assert final_item['isActive'].get('BOOL') == True, "isActive should still be True"
    print("   ✓ Persistence verified: isActive = True")

    print("\n" + "="*60)
    print("✓ TEST PASSED: Boolean update from false to true works!")
    print("="*60)


def cleanup():
    """Delete the test table."""
    try:
        dynamodb.delete_table(TableName=TABLE_NAME)
        print(f"\nCleaned up table: {TABLE_NAME}")
    except Exception as e:
        print(f"Cleanup warning: {e}")


if __name__ == '__main__':
    try:
        setup_table()
        test_boolean_update_with_set_and_remove()
        test_boolean_update_from_false_to_true()
        print("\n" + "="*60)
        print("ALL TESTS PASSED!")
        print("="*60)
    except AssertionError as e:
        print(f"\n✗ TEST FAILED: {e}")
        exit(1)
    except Exception as e:
        print(f"\n✗ ERROR: {e}")
        exit(1)
    finally:
        cleanup()

```
Result with v1.5.1 of Floci:
✗ TEST FAILED: isActive should exist after update


Pseudo-code Kotlin:
```
@DynamoDbBean
class SomeData {
  var uid: String? = null
  var textField: String? = null
  var booleanField: Boolean? = null
}

@SpringBootTest
@AutoConfigureMockMvc
class IntegrationTest {

  @Test
  fun `Test container behaviour`() {
    val uid = UUID.randomUUID().toString()
    val someData = SomeData()
    someData.uid = uid
    ddb.putItem(someData)

   // As expected, only uid is not null
    assertThat(db.getItem(uid)!!.booleanField).isNull()
    assertThat(db.getItem(uid)!!.uid).isNotNull()
    assertThat(db.getItem(uid)!!.textField).isNull()

   someData.textField = "abc"
   someData.booleanField = true
   ddb.updateItem(someData)

    // Expecting all fields to not be null and the boolean field to be true but that test fails
    assertThat(db.getItem(uid)!!.uid).isNotNull()
    assertEquals("abc", db.getItem(uid)!!.textField)
    assertEquals(true, db.getItem(uid)!!.booleanField)  <-- fails

}
```



## Environment

- Floci version / image tag: 1.5.1
- Java SDK version (if applicable): 25
- How you're running Floci (Docker / native / `mvn quarkus:dev`): Docker native and JVM
- Spring Boot 4.0.5
- mavenBom "software.amazon.awssdk:bom:2.42.30".  Also happens with v2.42.24.
- Kotlin 2.3.20



Discussion:

Patch:
diff --git a/pom.xml b/pom.xml
index 78e0a917..1f01b1d1 100644
--- a/pom.xml
+++ b/pom.xml
@@ -6,7 +6,7 @@
 
     <groupId>io.github.hectorvent</groupId>
     <artifactId>floci</artifactId>
-    <version>1.3.0</version>
+    <version>1.5.1</version>
     <packaging>jar</packaging>
 
     <name>Floci</name>
diff --git a/src/main/java/io/github/hectorvent/floci/services/dynamodb/DynamoDbService.java b/src/main/java/io/github/hectorvent/floci/services/dynamodb/DynamoDbService.java
index 134f29f5..0187636e 100644
--- a/src/main/java/io/github/hectorvent/floci/services/dynamodb/DynamoDbService.java
+++ b/src/main/java/io/github/hectorvent/floci/services/dynamodb/DynamoDbService.java
@@ -904,6 +904,8 @@ private String applySetClause(ObjectNode item, String clause,
                                    JsonNode exprAttrNames, JsonNode exprAttrValues) {
         // Parse comma-separated assignments: "attr = :val, #name = :val2"
         // Stop when we hit another clause keyword (REMOVE, ADD, DELETE) or end
+        LOG.debugv("applySetClause: clause={0}, exprAttrNames={1}, exprAttrValues={2}",
+                   clause, exprAttrNames, exprAttrValues);
         while (!clause.isEmpty()) {
             String upper = clause.toUpperCase();
             if (upper.startsWith("REMOVE ") || upper.startsWith("ADD ") || upper.startsWith("DELETE ")) {
@@ -920,23 +922,25 @@ private String applySetClause(ObjectNode item, String clause,
             String rest = clause.substring(eqIdx + 1).trim();
 
             // Find the value placeholder or expression
+            // IMPORTANT: Check for clause keywords FIRST, then commas
+            // This ensures we don't include REMOVE/ADD/DELETE clauses in value parts
             String valuePart;
+            int nextClause = findNextClauseKeyword(rest);
             int commaIdx = findNextComma(rest);
-            if (commaIdx >= 0) {
+
+            // If there's a clause keyword, use the earlier of comma or keyword
+            if (nextClause >= 0 && (commaIdx < 0 || nextClause < commaIdx)) {
+                valuePart = rest.substring(0, nextClause).trim();
+                rest = rest.substring(nextClause).trim();
+            } else if (commaIdx >= 0) {
                 valuePart = rest.substring(0, commaIdx).trim();
                 rest = rest.substring(commaIdx + 1).trim();
             } else {
-                // Check for next clause keyword
-                int nextClause = findNextClauseKeyword(rest);
-                if (nextClause >= 0) {
-                    valuePart = rest.substring(0, nextClause).trim();
-                    rest = rest.substring(nextClause).trim();
-                } else {
-                    valuePart = rest.trim();
-                    rest = "";
-                }
+                valuePart = rest.trim();
+                rest = "";
             }
 
+
             // Resolve the value
             if (valuePart.startsWith("if_not_exists(")) {
                 // if_not_exists(attrRef, fallbackExpr) evaluates to:
@@ -948,17 +952,17 @@ private String applySetClause(ObjectNode item, String clause,
                     String checkAttr = resolveAttributeName(args[0].trim(), exprAttrNames);
                     String fallbackExpr = args[1].trim();
                     JsonNode resolved;
-                    if (item.has(checkAttr)) {
+                    if (hasValueAtPath(item, checkAttr, exprAttrNames)) {
                         // attrRef exists — evaluate to its current value
-                        resolved = item.get(checkAttr);
+                        resolved = getValueAtPath(item, checkAttr, exprAttrNames);
                     } else if (fallbackExpr.startsWith(":") && exprAttrValues != null) {
                         resolved = exprAttrValues.get(fallbackExpr);
                     } else {
                         // fallback is itself an attribute reference
-                        resolved = item.get(resolveAttributeName(fallbackExpr, exprAttrNames));
+                        resolved = getValueAtPath(item, resolveAttributeName(fallbackExpr, exprAttrNames), exprAttrNames);
                     }
                     if (resolved != null) {
-                        item.set(attrName, resolved);
+                        setValueAtPath(item, attrPath, resolved, exprAttrNames);
                     }
                 }
             } else if (valuePart.startsWith("list_append(")) {
@@ -985,15 +989,20 @@ private String applySetClause(ObjectNode item, String clause,
                 }
             } else if (valuePart.startsWith(":") && exprAttrValues != null) {
                 JsonNode value = exprAttrValues.get(valuePart);
+                LOG.debugv("applySetClause: looked up valuePart={0} in exprAttrValues, got value={1}",
+                           valuePart, value);
                 if (value != null) {
-                    item.set(attrName, value);
+                    setValueAtPath(item, attrPath, value, exprAttrNames);
+                    LOG.debugv("applySetClause: set attrPath={0} to value={1}", attrPath, value);
+                } else {
+                    LOG.debugv("applySetClause: value was null for valuePart={0}, NOT setting attribute", valuePart);
                 }
             } else if (!valuePart.isEmpty()) {
                 // Plain attribute reference: SET a = b  or  SET a = #alias
                 String refAttr = resolveAttributeName(valuePart, exprAttrNames);
-                JsonNode refValue = item.get(refAttr);
+                JsonNode refValue = getValueAtPath(item, refAttr, exprAttrNames);
                 if (refValue != null) {
-                    item.set(attrName, refValue);
+                    setValueAtPath(item, attrPath, refValue, exprAttrNames);
                 }
             }
 
@@ -1149,6 +1158,95 @@ String resolveAttributeName(String nameOrPlaceholder, JsonNode exprAttrNames) {
         return nameOrPlaceholder;
     }
 
+    /**
+     * Sets a value at a potentially nested attribute path.
+     * Supports paths like "attr", "parent.child", "#alias.nested" etc.
+     * For nested paths, navigates into the DynamoDB Map structure (M field).
+     */
+    private void setValueAtPath(ObjectNode item, String path, JsonNode value, JsonNode exprAttrNames) {
+        // Resolve any # placeholders in the path segments
+        String[] segments = path.split("\\.");
+        for (int i = 0; i < segments.length; i++) {
+            segments[i] = resolveAttributeName(segments[i].trim(), exprAttrNames);
+        }
+
+        if (segments.length == 1) {
+            // Simple top-level attribute
+            item.set(segments[0], value);
+            return;
+        }
+
+        // Navigate to the parent of the target attribute
+        ObjectNode current = item;
+        for (int i = 0; i < segments.length - 1; i++) {
+            String segment = segments[i];
+            JsonNode child = current.get(segment);
+
+            if (child == null || !child.has("M")) {
+                // Create nested map structure if it doesn't exist
+                ObjectNode newMap = com.fasterxml.jackson.databind.node.JsonNodeFactory.instance.objectNode();
+                ObjectNode wrapper = com.fasterxml.jackson.databind.node.JsonNodeFactory.instance.objectNode();
+                wrapper.set("M", newMap);
+                current.set(segment, wrapper);
+                current = newMap;
+            } else {
+                // Navigate into existing map
+                JsonNode mapContent = child.get("M");
+                if (mapContent instanceof ObjectNode) {
+                    current = (ObjectNode) mapContent;
+                } else {
+                    // Cannot navigate further, structure mismatch
+                    return;
+                }
+            }
+        }
+
+        // Set the final attribute
+        current.set(segments[segments.length - 1], value);
+    }
+
+    /**
+     * Gets a value at a potentially nested attribute path.
+     * Returns null if the path doesn't exist.
+     */
+    private JsonNode getValueAtPath(JsonNode item, String path, JsonNode exprAttrNames) {
+        // Resolve any # placeholders in the path segments
+        String[] segments = path.split("\\.");
+        for (int i = 0; i < segments.length; i++) {
+            segments[i] = resolveAttributeName(segments[i].trim(), exprAttrNames);
+        }
+
+        JsonNode current = item;
+        for (int i = 0; i < segments.length; i++) {
+            if (current == null) return null;
+
+            String segment = segments[i];
+            JsonNode child = current.get(segment);
+
+            if (child == null) return null;
+
+            if (i < segments.length - 1) {
+                // Need to navigate deeper - check for Map structure
+                if (child.has("M")) {
+                    current = child.get("M");
+                } else {
+                    return null;
+                }
+            } else {
+                // This is the final segment
+                return child;
+            }
+        }
+        return current;
+    }
+
+    /**
+     * Checks if a value exists at a potentially nested attribute path.
+     */
+    private boolean hasValueAtPath(JsonNode item, String path, JsonNode exprAttrNames) {
+        return getValueAtPath(item, path, exprAttrNames) != null;
+    }
+
     private int findNextComma(String s) {
         // Find next comma that is not inside a function call
         int depth = 0;


Test patch:
diff --git a/compatibility-tests/sdk-test-java/pom.xml b/compatibility-tests/sdk-test-java/pom.xml
index 31f19176..09e6f6e0 100644
--- a/compatibility-tests/sdk-test-java/pom.xml
+++ b/compatibility-tests/sdk-test-java/pom.xml
@@ -16,8 +16,8 @@
         <maven.compiler.source>17</maven.compiler.source>
         <maven.compiler.target>17</maven.compiler.target>
         <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
-        <aws.sdk.version>2.31.8</aws.sdk.version>
-        <awsjavasdk.version>2.31.8</awsjavasdk.version>
+        <aws.sdk.version>2.42.24</aws.sdk.version>
+        <awsjavasdk.version>2.42.24</awsjavasdk.version>
     </properties>
 
     <dependencyManagement>
@@ -70,6 +70,10 @@
             <groupId>software.amazon.awssdk</groupId>
             <artifactId>dynamodb</artifactId>
         </dependency>
+        <dependency>
+            <groupId>software.amazon.awssdk</groupId>
+            <artifactId>dynamodb-enhanced</artifactId>
+        </dependency>
         <dependency>
             <groupId>software.amazon.awssdk</groupId>
             <artifactId>lambda</artifactId>
diff --git a/compatibility-tests/sdk-test-java/src/main/java/com/floci/test/TestFixtures.java b/compatibility-tests/sdk-test-java/src/main/java/com/floci/test/TestFixtures.java
index 372aa52e..5408701e 100644
--- a/compatibility-tests/sdk-test-java/src/main/java/com/floci/test/TestFixtures.java
+++ b/compatibility-tests/sdk-test-java/src/main/java/com/floci/test/TestFixtures.java
@@ -39,10 +39,15 @@
  */
 public final class TestFixtures {
 
-    private static final URI ENDPOINT = URI.create(
-            System.getenv("FLOCI_ENDPOINT") != null
-                    ? System.getenv("FLOCI_ENDPOINT")
-                    : "http://localhost:4566");
+    private static final URI ENDPOINT;
+
+    static {
+        String endpointStr = System.getenv("FLOCI_ENDPOINT");
+        if (endpointStr == null || endpointStr.trim().isEmpty()) {
+            endpointStr = "http://localhost:4566";
+        }
+        ENDPOINT = URI.create(endpointStr);
+    }
 
     private static final Region REGION = Region.US_EAST_1;
 
diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/DynamoDbEnhancedClientTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/DynamoDbEnhancedClientTest.java
new file mode 100644
index 00000000..75161fc3
--- /dev/null
+++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/DynamoDbEnhancedClientTest.java
@@ -0,0 +1,190 @@
+package com.floci.test;
+
+import org.junit.jupiter.api.*;
+import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient;
+import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable;
+import software.amazon.awssdk.enhanced.dynamodb.TableSchema;
+import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean;
+import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey;
+import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
+import software.amazon.awssdk.services.dynamodb.model.DeleteTableRequest;
+
+import static org.assertj.core.api.Assertions.*;
+
+/**
+ * Tests for the DynamoDB Enhanced Client against Floci.
+ *
+ */
+@DisplayName("DynamoDB Enhanced Client")
+@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
+class DynamoDbEnhancedClientTest {
+
+    private static DynamoDbClient dynamoDbClient;
+    private static DynamoDbEnhancedClient enhancedClient;
+    private static DynamoDbTable<UserData> userTable;
+    private static final String TABLE_NAME = "Users";
+
+    @BeforeAll
+    static void setUp() {
+        dynamoDbClient = TestFixtures.dynamoDbClient();
+        enhancedClient = DynamoDbEnhancedClient.builder()
+                .dynamoDbClient(dynamoDbClient)
+                .build();
+
+        userTable = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(UserData.class));
+        userTable.createTable();
+    }
+
+    @AfterAll
+    static void cleanUp() {
+        if (dynamoDbClient != null) {
+            try {
+                dynamoDbClient.deleteTable(DeleteTableRequest.builder().tableName(TABLE_NAME).build());
+            } catch (Exception ignored) {}
+            dynamoDbClient.close();
+        }
+    }
+
+    /**
+     * Test updating a nullable boolean from null to true.
+     * This mimics the Kotlin scenario for optional, nullable, variables in a class like: var useLoyaltyPoints: Boolean? = null
+     *
+     * The Enhanced Client generates: SET field1 = :val1, ..., boolField = :boolVal REMOVE nullField1, ...
+     * The bug was that boolField would not be set because the parser incorrectly
+     * included "REMOVE nullField1" in the value lookup.
+     */
+    @Test
+    @Order(1)
+    void testUpdateNullableBooleanFromNullToTrue() {
+        String userId = "user-" + System.currentTimeMillis();
+
+        // Step 1: Create user WITHOUT the boolean field set
+        UserData user = new UserData();
+        user.setUserId(userId);
+        user.setEntries("initial entries");
+        long createdTimestampInMillis = 1234567890L;
+        user.setCreated(createdTimestampInMillis);
+        // isActive is null (not set)
+        user.setTempField("temporary data");
+
+        userTable.putItem(user);
+
+        // Verify initial state
+        UserData initial = userTable.getItem(r -> r.key(k -> k.partitionValue(userId)));
+        assertThat(initial).isNotNull();
+        assertThat(initial.getIsActive()).isNull();
+        assertThat(initial.getTempField()).isEqualTo("temporary data");
+        assertThat(initial.getCreated()).isEqualTo(createdTimestampInMillis);
+
+        // Step 2: Update user - set boolean to true and other fields
+        // The Enhanced Client will generate a SET clause with multiple fields
+        // followed by a REMOVE clause for null fields
+        user.setIsActive(true);  // Set boolean to true
+        user.setEntries("updated entries");
+        user.setTempField(null);  // This will be REMOVED
+        user.setCreated(null);    // This will be REMOVED
+
+        userTable.updateItem(user);
+
+        // Step 3: Get the item and verify the boolean was set correctly
+        UserData updated = userTable.getItem(r -> r.key(k -> k.partitionValue(userId)));
+
+        assertThat(updated).isNotNull();
+        assertThat(updated.getIsActive())
+                .as("isActive should be true after update")
+                .isTrue();
+        assertThat(updated.getEntries()).isEqualTo("updated entries");
+        assertThat(updated.getTempField()).isNull();
+        assertThat(updated.getCreated()).isNull();
+    }
+
+    /**
+     * Test updating a boolean from false to true with Enhanced Client.
+     */
+    @Test
+    @Order(2)
+    void testUpdateBooleanFromFalseToTrue() {
+        String userId = "user-" + System.currentTimeMillis();
+
+        // Step 1: Create user WITH boolean = false
+        UserData user = new UserData();
+        user.setUserId(userId);
+        user.setEntries("initial");
+        user.setIsActive(false);
+        user.setTempField("temp");
+
+        userTable.putItem(user);
+
+        // Verify initial state
+        UserData initial = userTable.getItem(r -> r.key(k -> k.partitionValue(userId)));
+        assertThat(initial.getIsActive()).isFalse();
+
+        // Step 2: Update boolean to true
+        user.setIsActive(true);
+        user.setEntries("updated");
+        user.setTempField(null);  // Will be REMOVED
+
+        userTable.updateItem(user);
+
+        // Step 3: Verify the boolean changed to true
+        UserData updated = userTable.getItem(r -> r.key(k -> k.partitionValue(userId)));
+
+        assertThat(updated).isNotNull();
+        assertThat(updated.getIsActive())
+                .as("isActive should be true after update from false")
+                .isTrue();
+        assertThat(updated.getEntries()).isEqualTo("updated");
+        assertThat(updated.getTempField()).isNull();
+    }
+
+    @DynamoDbBean
+    public static class UserData {
+        private String userId;
+        private String entries;
+        private Long created;
+        private Boolean isActive;
+        private String tempField;
+
+        @DynamoDbPartitionKey
+        public String getUserId() {
+            return userId;
+        }
+
+        public void setUserId(String userId) {
+            this.userId = userId;
+        }
+
+        public String getEntries() {
+            return entries;
+        }
+
+        public void setEntries(String entries) {
+            this.entries = entries;
+        }
+
+        public Long getCreated() {
+            return created;
+        }
+
+        public void setCreated(Long created) {
+            this.created = created;
+        }
+
+        public Boolean getIsActive() {
+            return isActive;
+        }
+
+        public void setIsActive(Boolean isActive) {
+            this.isActive = isActive;
+        }
+
+        public String getTempField() {
+            return tempField;
+        }
+
+        public void setTempField(String tempField) {
+            this.tempField = tempField;
+        }
+    }
+}
+
diff --git a/src/test/java/io/github/hectorvent/floci/services/dynamodb/DynamoDbServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/dynamodb/DynamoDbServiceTest.java
index d8501594..2f596ae2 100644
--- a/src/test/java/io/github/hectorvent/floci/services/dynamodb/DynamoDbServiceTest.java
+++ b/src/test/java/io/github/hectorvent/floci/services/dynamodb/DynamoDbServiceTest.java
@@ -813,4 +813,70 @@ void updateItemSetAddsToStringSet() {
         assertTrue(tagValues.containsAll(Arrays.asList("a", "b")));
     }
 
+
+    /**
+     * Test update with SET and REMOVE in the same expression.
+     * This mimics how the DynamoDB Enhanced Client generates expressions
+     * when ignoreNulls is false - it sets non-null fields and removes null fields.
+     *
+     * Using Spring Boot 4.0.5, AWS SDK v2 2.42.24, setting a boolean field to true after it was not created at row-creation time, would not set the value to true.
+     */
+    @Test
+    void testUpdateWithSetAndRemoveCombined() {
+        createUsersTable();
+
+        // Put initial item WITHOUT the boolean field
+        ObjectNode initialItem = mapper.createObjectNode();
+        initialItem.set("userId", attributeValue("S", "user-123"));
+        initialItem.set("created", attributeValue("N", "1234567890"));
+        initialItem.set("entries", attributeValue("S", "initial"));
+        initialItem.set("tempField", attributeValue("S", "to be removed"));
+        service.putItem("Users", initialItem);
+
+        // Verify initial state - isActive doesn't exist
+        ObjectNode key = mapper.createObjectNode();
+        key.set("userId", attributeValue("S", "user-123"));
+        JsonNode beforeUpdate = service.getItem("Users", key);
+        assertFalse(beforeUpdate.has("isActive"), "isActive should not exist initially");
+        assertTrue(beforeUpdate.has("tempField"), "tempField should exist initially");
+
+        // Update with SET and REMOVE - like Enhanced Client does
+        ObjectNode exprAttrNames = mapper.createObjectNode();
+        exprAttrNames.put("#entries", "entries");
+        exprAttrNames.put("#isActive", "isActive");
+        exprAttrNames.put("#tempField", "tempField");
+        exprAttrNames.put("#created", "created");
+
+        ObjectNode exprAttrValues = mapper.createObjectNode();
+        exprAttrValues.set(":entries", attributeValue("S", "updated entries"));
+        exprAttrValues.set(":isActive", boolAttributeValue(true));
+
+        // This is the key expression: SET multiple fields, then REMOVE multiple fields
+        String updateExpr = "SET #entries = :entries, #isActive = :isActive REMOVE #tempField, #created";
+
+        DynamoDbService.UpdateResult result = service.updateItem("Users", key, null,
+                updateExpr, exprAttrNames, exprAttrValues, "ALL_NEW");
+
+        // Verify the result
+        JsonNode newItem = result.newItem();
+        assertNotNull(newItem, "result should have newItem");
+
+        // Boolean should be set to true
+        assertTrue(newItem.has("isActive"), "isActive should exist");
+        assertTrue(newItem.get("isActive").has("BOOL"), "isActive should be BOOL type");
+        assertTrue(newItem.get("isActive").get("BOOL").asBoolean(), "isActive should be true");
+
+        // entries should be updated
+        assertEquals("updated entries", newItem.get("entries").get("S").asText());
+
+        // tempField and created should be removed
+        assertFalse(newItem.has("tempField"), "tempField should be removed");
+        assertFalse(newItem.has("created"), "created should be removed");
+
+        // Get item to double-check persistence
+        JsonNode stored = service.getItem("Users", key);
+        assertTrue(stored.get("isActive").get("BOOL").asBoolean(),
+                "isActive should still be true after get");
+    }
+
 }