getCondensedActiveAttachments(
diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/ModifierToCreateFlatCQN.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/ModifierToCreateFlatCQN.java
new file mode 100644
index 00000000..9cf1c8c1
--- /dev/null
+++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/ModifierToCreateFlatCQN.java
@@ -0,0 +1,91 @@
+/*
+ * © 2024-2025 SAP SE or an SAP affiliate company and cds-feature-attachments contributors.
+ */
+package com.sap.cds.feature.attachments.handler.draftservice;
+
+import com.sap.cds.ql.CQL;
+import com.sap.cds.ql.RefBuilder;
+import com.sap.cds.ql.RefBuilder.RefSegment;
+import com.sap.cds.ql.StructuredTypeRef;
+import com.sap.cds.ql.Value;
+import com.sap.cds.ql.cqn.CqnComparisonPredicate.Operator;
+import com.sap.cds.ql.cqn.CqnPredicate;
+import com.sap.cds.ql.cqn.CqnStructuredTypeRef;
+import com.sap.cds.ql.cqn.Modifier;
+import com.sap.cds.services.draft.Drafts;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * A CQL modifier that transforms entity references for draft/active entity handling.
+ *
+ * This modifier flattens complex entity references by removing nested references and creating a
+ * new CQN statement for the specified {@code fullEntityName}. It performs the following
+ * transformations:
+ *
+ *
+ * - Removes nested references and creates a new entity reference for {@code fullEntityName}
+ *
- Preserves the filter from the last segment of the original {@link CqnStructuredTypeRef}
+ *
- Adds an {@code IsActiveEntity} filter with the specified boolean value
+ *
+ *
+ * This is primarily used in draft service scenarios to transform queries between draft entities
+ * (IsActiveEntity = false) and active entities (IsActiveEntity = true).
+ */
+class ModifierToCreateFlatCQN implements Modifier {
+
+ private static final Logger logger = LoggerFactory.getLogger(ModifierToCreateFlatCQN.class);
+
+ private final boolean isActiveEntity;
+ private final String fullEntityName;
+
+ ModifierToCreateFlatCQN(boolean isActiveEntity, String fullEntityName) {
+ this.isActiveEntity = isActiveEntity;
+ this.fullEntityName = fullEntityName;
+ }
+
+ @Override
+ public CqnStructuredTypeRef ref(CqnStructuredTypeRef original) {
+ RefBuilder ref = CQL.copy(original);
+ RefSegment rootSegment = ref.rootSegment();
+ logger.debug(
+ "Modifying ref {} with isActiveEntity: {} and fullEntityName: {}",
+ rootSegment,
+ isActiveEntity,
+ fullEntityName);
+
+ // Get the filter from the last segment:
+ // Get the last segment with targetSegment, then an Optional with filter()
+ // which is then unwrapped to CqnPredicate or null by orElse(null).
+ CqnPredicate lastSegmentFilter = original.targetSegment().filter().orElse(null);
+
+ // Create an IsActiveEntity filter
+ CqnPredicate isActiveEntityFilter = CQL.get(Drafts.IS_ACTIVE_ENTITY).eq(isActiveEntity);
+
+ // Combine with original filter if it exists
+ CqnPredicate combinedFilter =
+ lastSegmentFilter != null
+ ? CQL.and(lastSegmentFilter, isActiveEntityFilter)
+ : isActiveEntityFilter;
+
+ // Apply any additional modifications (like replacing other IsActiveEntity references)
+ // This calls the comparison() method below for each comparison in the filter
+ CqnPredicate modifiedFilter = CQL.copy(combinedFilter, this);
+
+ // Create a new entity reference with the modified filter
+ return CQL.entity(fullEntityName).filter(modifiedFilter).asRef();
+ }
+
+ @Override
+ public CqnPredicate comparison(Value> lhs, Operator op, Value> rhs) {
+ Value> rhsNew = rhs;
+ Value> lhsNew = lhs;
+ if (lhs.isRef() && Drafts.IS_ACTIVE_ENTITY.equals(lhs.asRef().lastSegment())) {
+ rhsNew = CQL.constant(isActiveEntity);
+ }
+ if (rhs.isRef() && Drafts.IS_ACTIVE_ENTITY.equals(rhs.asRef().lastSegment())) {
+ lhsNew = CQL.constant(isActiveEntity);
+ }
+ return CQL.comparison(lhsNew, op, rhsNew);
+ }
+}
diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/configuration/RegistrationTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/configuration/RegistrationTest.java
index 6ff55dd1..b0229cd5 100644
--- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/configuration/RegistrationTest.java
+++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/configuration/RegistrationTest.java
@@ -110,7 +110,30 @@ void handlersAreRegistered() {
var handlerSize = 8;
verify(configurer, times(handlerSize)).eventHandler(handlerArgumentCaptor.capture());
- var handlers = handlerArgumentCaptor.getAllValues();
+ checkHandlers(handlerArgumentCaptor.getAllValues(), handlerSize);
+ }
+
+ @Test
+ void handlersAreRegisteredWithoutOutboxService() {
+ when(serviceCatalog.getService(PersistenceService.class, PersistenceService.DEFAULT_NAME))
+ .thenReturn(persistenceService);
+ when(serviceCatalog.getService(AttachmentService.class, AttachmentService.DEFAULT_NAME))
+ .thenReturn(attachmentService);
+ when(serviceCatalog.getServices(DraftService.class)).thenReturn(Stream.of(draftService));
+ when(serviceCatalog.getServices(ApplicationService.class))
+ .thenReturn(Stream.of(applicationService));
+ // Return null for OutboxService to test the missing branch
+ when(serviceCatalog.getService(OutboxService.class, OutboxService.PERSISTENT_UNORDERED_NAME))
+ .thenReturn(null);
+
+ cut.eventHandlers(configurer);
+
+ var handlerSize = 8;
+ verify(configurer, times(handlerSize)).eventHandler(handlerArgumentCaptor.capture());
+ checkHandlers(handlerArgumentCaptor.getAllValues(), handlerSize);
+ }
+
+ private void checkHandlers(List handlers, int handlerSize) {
assertThat(handlers).hasSize(handlerSize);
isHandlerForClassIncluded(handlers, DefaultAttachmentsServiceHandler.class);
isHandlerForClassIncluded(handlers, CreateAttachmentsHandler.class);
diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandlerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandlerTest.java
index 5efbd1be..f96138c4 100644
--- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandlerTest.java
+++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandlerTest.java
@@ -6,10 +6,7 @@
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.verifyNoInteractions;
-import static org.mockito.Mockito.when;
+import static org.mockito.Mockito.*;
import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments;
import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.testservice.Attachment;
@@ -70,6 +67,24 @@ void whereConditionIncludedNothingHappens() {
verifyNoInteractions(attachmentsReader, deleteContentAttachmentEvent);
}
+ @Test
+ void entityHasNoAttachmentsAndIsNotAttachmentEntityNothingHappens() {
+ // Test the case where isAttachmentEntity and hasAttachmentAssociations both return false
+ CdsEntity mockEntity = mock(CdsEntity.class);
+ // Entity has no elements with name "attachment"
+ when(mockEntity.getQualifiedName())
+ .thenReturn("TestService.RegularEntity"); // No "Attachment" in name
+ when(eventContext.getTarget()).thenReturn(mockEntity);
+
+ CqnDelete mockDelete = mock(CqnDelete.class);
+ when(mockDelete.where()).thenReturn(Optional.empty());
+ when(eventContext.getCqn()).thenReturn(mockDelete);
+
+ cut.processBeforeDraftCancel(eventContext);
+
+ verifyNoInteractions(attachmentsReader);
+ }
+
@Test
void nothingSelectedNothingToDo() {
getEntityAndMockContext(RootTable_.CDS_NAME);
@@ -84,6 +99,33 @@ void nothingSelectedNothingToDo() {
@Test
void attachmentReaderCorrectCalled() {
+ getEntityAndMockContext(Attachment_.CDS_NAME);
+ CqnDelete delete = Delete.from(Attachment_.class);
+ when(eventContext.getCqn()).thenReturn(delete);
+ when(eventContext.getModel()).thenReturn(runtime.getCdsModel());
+
+ cut.processBeforeDraftCancel(eventContext);
+
+ CdsEntity target = eventContext.getTarget();
+ verify(attachmentsReader)
+ .readAttachments(eq(runtime.getCdsModel()), eq(target), deleteArgumentCaptor.capture());
+ // Check if the modified CqnDelete that is passed to readAttachments looks correct
+ CqnDelete modifiedCQN = deleteArgumentCaptor.getValue();
+ assertThat(modifiedCQN.toJson())
+ .isEqualTo(
+ "{\"DELETE\":{\"from\":{\"ref\":[{\"id\":\"unit.test.TestService.Attachment\",\"where\":[{\"ref\":[\"IsActiveEntity\"]},\"=\",{\"val\":true}]}]}}}");
+
+ deleteArgumentCaptor = ArgumentCaptor.forClass(CqnDelete.class);
+ CdsEntity siblingTarget = target.getTargetOf(Drafts.SIBLING_ENTITY);
+ verify(attachmentsReader)
+ .readAttachments(
+ eq(runtime.getCdsModel()), eq(siblingTarget), deleteArgumentCaptor.capture());
+ CqnDelete siblingDelete = deleteArgumentCaptor.getValue();
+ assertThat(siblingDelete.toJson()).isNotEqualTo(delete.toJson());
+ }
+
+ @Test
+ void attachmentReaderCorrectCalledForEntityWithAttachmentAssociations() {
getEntityAndMockContext(RootTable_.CDS_NAME);
CqnDelete delete = Delete.from(RootTable_.class);
when(eventContext.getCqn()).thenReturn(delete);
@@ -94,8 +136,11 @@ void attachmentReaderCorrectCalled() {
CdsEntity target = eventContext.getTarget();
verify(attachmentsReader)
.readAttachments(eq(runtime.getCdsModel()), eq(target), deleteArgumentCaptor.capture());
- CqnDelete originDelete = deleteArgumentCaptor.getValue();
- assertThat(originDelete.toJson()).isEqualTo(delete.toJson());
+ // Check if the modified CqnDelete that is passed to readAttachments looks correct
+ CqnDelete modifiedCQN = deleteArgumentCaptor.getValue();
+ assertThat(modifiedCQN.toJson())
+ .isEqualTo(
+ "{\"DELETE\":{\"from\":{\"ref\":[{\"id\":\"unit.test.TestService.RootTable\",\"where\":[{\"ref\":[\"IsActiveEntity\"]},\"=\",{\"val\":true}]}]}}}");
deleteArgumentCaptor = ArgumentCaptor.forClass(CqnDelete.class);
CdsEntity siblingTarget = target.getTargetOf(Drafts.SIBLING_ENTITY);
@@ -108,8 +153,8 @@ void attachmentReaderCorrectCalled() {
@Test
void modifierCalledWithCorrectEntitiesIfDraftIsInContext() {
- getEntityAndMockContext(RootTable_.CDS_NAME + DraftUtils.DRAFT_TABLE_POSTFIX);
- CqnDelete delete = Delete.from(RootTable_.class);
+ getEntityAndMockContext(Attachment_.CDS_NAME + DraftUtils.DRAFT_TABLE_POSTFIX);
+ CqnDelete delete = Delete.from(Attachment_.class);
when(eventContext.getCqn()).thenReturn(delete);
when(eventContext.getModel()).thenReturn(runtime.getCdsModel());
@@ -123,7 +168,9 @@ void modifierCalledWithCorrectEntitiesIfDraftIsInContext() {
.readAttachments(
eq(runtime.getCdsModel()), eq(siblingTarget), deleteArgumentCaptor.capture());
CqnDelete siblingDelete = deleteArgumentCaptor.getValue();
- assertThat(siblingDelete.toJson()).isEqualTo(delete.toJson());
+ assertThat(siblingDelete.toJson())
+ .isEqualTo(
+ "{\"DELETE\":{\"from\":{\"ref\":[{\"id\":\"unit.test.TestService.Attachment\",\"where\":[{\"ref\":[\"IsActiveEntity\"]},\"=\",{\"val\":true}]}]}}}");
}
@Test
diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/ActiveEntityModifierTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/ModifierToCreateFlatCQNTest.java
similarity index 57%
rename from cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/ActiveEntityModifierTest.java
rename to cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/ModifierToCreateFlatCQNTest.java
index 9024bf01..26d0541d 100644
--- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/ActiveEntityModifierTest.java
+++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/ModifierToCreateFlatCQNTest.java
@@ -12,7 +12,7 @@
import com.sap.cds.ql.cqn.CqnSelect;
import org.junit.jupiter.api.Test;
-class ActiveEntityModifierTest {
+class ModifierToCreateFlatCQNTest {
private static final String TEST_DRAFT_SERVICE_BOOKS = "test.DraftService.Books";
@@ -20,7 +20,7 @@ class ActiveEntityModifierTest {
void activeEntityReplacedToFalse() {
var select = Select.from(RootTable_.class).where(root -> root.IsActiveEntity().eq(true));
- var result = CQL.copy(select, new ActiveEntityModifier(false, RootTable_.CDS_NAME));
+ var result = CQL.copy(select, new ModifierToCreateFlatCQN(false, RootTable_.CDS_NAME));
assertThat(result.toString())
.contains("{\"ref\":[\"IsActiveEntity\"]},\"=\",{\"val\":false}]}");
@@ -31,28 +31,36 @@ void activeEntityReplacedToFalse() {
void activeEntityReplacedToTrue() {
var select = Select.from(RootTable_.class).where(root -> root.IsActiveEntity().eq(false));
- var result = CQL.copy(select, new ActiveEntityModifier(true, RootTable_.CDS_NAME));
+ var result = CQL.copy(select, new ModifierToCreateFlatCQN(true, RootTable_.CDS_NAME));
assertThat(result.toString()).contains("{\"ref\":[\"IsActiveEntity\"]},\"=\",{\"val\":true}]}");
assertThat(result.toString()).doesNotContain("false");
}
@Test
- void entityNameReplaced() {
+ void entityNameReplacedAndActiveEntity() {
var select = Select.from(RootTable_.class).where(root -> root.IsActiveEntity().eq(true));
- var result = CQL.copy(select, new ActiveEntityModifier(true, RootTable_.CDS_NAME + "_draft"));
+ var result =
+ CQL.copy(select, new ModifierToCreateFlatCQN(true, RootTable_.CDS_NAME + "_draft"));
- assertThat(result.toString()).contains("{\"ref\":[\"unit.test.TestService.RootTable_draft\"]}");
+ // Expects the entity to have an IsActiveEntity filter in the reference
+ assertThat(result.toString())
+ .contains(
+ "{\"id\":\"unit.test.TestService.RootTable_draft\",\"where\":[{\"ref\":[\"IsActiveEntity\"]},\"=\",{\"val\":true}]}");
}
@Test
- void nothingReplaced() {
+ void entityNameNotReplacedAndActiveEntity() {
var select = Select.from(RootTable_.class).where(root -> root.IsActiveEntity().eq(true));
- var result = CQL.copy(select, new ActiveEntityModifier(true, RootTable_.CDS_NAME));
+ var result = CQL.copy(select, new ModifierToCreateFlatCQN(true, RootTable_.CDS_NAME));
- assertThat(result).hasToString(select.toString());
+ // Expects the entity to have an IsActiveEntity filter in the reference even when entity name
+ // doesn't change
+ assertThat(result.toString())
+ .contains(
+ "{\"id\":\"unit.test.TestService.RootTable\",\"where\":[{\"ref\":[\"IsActiveEntity\"]},\"=\",{\"val\":true}]}");
}
@Test
@@ -66,7 +74,7 @@ void selectWithFilterReplace() {
.filter(e -> e.get(IS_ACTIVE_ENTITY).eq(false))
.to("relatedBook"));
- var result = CQL.copy(select, new ActiveEntityModifier(true, TEST_DRAFT_SERVICE_BOOKS));
+ var result = CQL.copy(select, new ModifierToCreateFlatCQN(true, TEST_DRAFT_SERVICE_BOOKS));
assertThat(result.toString()).contains("{\"ref\":[\"IsActiveEntity\"]},\"=\",{\"val\":true}]}");
assertThat(result.toString()).doesNotContain("false");
@@ -88,11 +96,34 @@ void onlyRefActiveEntityIsReplaced() {
.eq(root.IsActiveEntity())
.and(CQL.constant(true).eq(root.HasActiveEntity())))));
- var result = CQL.copy(select, new ActiveEntityModifier(false, RootTable_.CDS_NAME));
+ var result = CQL.copy(select, new ModifierToCreateFlatCQN(false, RootTable_.CDS_NAME));
assertThat(result.toString()).contains("{\"ref\":[\"IsActiveEntity\"]},\"=\",{\"val\":false}");
assertThat(result.toString()).contains("{\"ref\":[\"HasActiveEntity\"]},\"=\",{\"val\":true}");
assertThat(result.toString()).contains("{\"val\":false},\"=\",{\"ref\":[\"IsActiveEntity\"]}");
assertThat(result.toString()).contains("{\"val\":true},\"=\",{\"ref\":[\"HasActiveEntity\"]}");
}
+
+ @Test
+ void combinesNonIsActiveEntityFilterWithIsActiveEntityFilter() {
+ // Create query with a filter on the last/target segment
+ CqnSelect original =
+ Select.from(
+ CQL.entity(RootTable_.CDS_NAME)
+ .filter(CQL.get("title").eq("Some Title")) // Filter on entity reference
+ );
+
+ ModifierToCreateFlatCQN modifier = new ModifierToCreateFlatCQN(true, RootTable_.CDS_NAME);
+
+ var result = CQL.copy(original, modifier);
+
+ // Should contain both the original title filter and IsActiveEntity filter
+ assertThat(result.toString()).contains("\"title\"");
+ assertThat(result.toString()).contains("\"Some Title\"");
+ assertThat(result.toString()).contains("\"IsActiveEntity\"");
+ assertThat(result.toString()).contains("\"val\":true");
+
+ // The key assertion: should contain AND operation combining both filters
+ assertThat(result.toString()).contains("\"and\"");
+ }
}