Skip to content

Commit b7c9994

Browse files
authored
Change ActiveEntityModifier and add further checks to DraftCancelAttachmentsHandler (#669)
1 parent 8c22165 commit b7c9994

File tree

6 files changed

+253
-95
lines changed

6 files changed

+253
-95
lines changed

cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/ActiveEntityModifier.java

Lines changed: 0 additions & 69 deletions
This file was deleted.

cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandler.java

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,10 @@ public DraftCancelAttachmentsHandler(
5858
@Before
5959
@HandlerOrder(HandlerOrder.LATE)
6060
void processBeforeDraftCancel(DraftCancelEventContext context) {
61-
if (isWhereEmpty(context)) {
61+
// We only process the draft cancel event if there is no WHERE clause in the CQN
62+
// and if the target entity is an attachment entity or has attachment associations.
63+
if ((isAttachmentEntity(context.getTarget()) || hasAttachmentAssociations(context.getTarget()))
64+
&& isWhereEmpty(context)) {
6265
logger.debug(
6366
"Processing before {} event for entity {}", context.getEvent(), context.getTarget());
6467

@@ -98,17 +101,49 @@ private Validator buildDeleteContentValidator(
98101
};
99102
}
100103

104+
// This function checks if the WHERE clause of the CQN is empty.
105+
// This is the current way to verify that we are really cancelling a draft and not doing sth else.
106+
// Also see here:
107+
// https://github.com/cap-java/cds-feature-attachments/blob/main/doc/Design.md#events
108+
// Unfortunately, context.getEvent() does not return a reliable value in this case.
101109
private boolean isWhereEmpty(DraftCancelEventContext context) {
102110
return context.getCqn().where().isEmpty();
103111
}
104112

113+
// This function checks if the given entity is of type Attachments
114+
private boolean isAttachmentEntity(CdsEntity entity) {
115+
boolean hasAttachmentInName = entity.getQualifiedName().toLowerCase().contains("attachment");
116+
117+
boolean hasFileNameElement =
118+
entity.elements().anyMatch(element -> Attachments.FILE_NAME.equals(element.getName()));
119+
120+
logger.debug(
121+
"Entity: {}, hasAttachmentInName: {}, hasFileNameElement: {}",
122+
entity.getQualifiedName(),
123+
hasAttachmentInName,
124+
hasFileNameElement);
125+
126+
return hasAttachmentInName || hasFileNameElement;
127+
}
128+
129+
// This function checks if the given entity has attachment associations.
130+
private boolean hasAttachmentAssociations(CdsEntity entity) {
131+
return entity
132+
.elements()
133+
.anyMatch(element -> element.getName().toLowerCase().contains("attachment"));
134+
}
135+
105136
private List<Attachments> readAttachments(
106137
DraftCancelEventContext context, CdsStructuredType entity, boolean isActiveEntity) {
107-
CqnDelete cqnInactiveEntity =
138+
logger.debug(
139+
"Reading attachments for entity {} (isActiveEntity={})", entity.getName(), isActiveEntity);
140+
logger.debug("Original CQN: {}", context.getCqn());
141+
CqnDelete modifiedCQN =
108142
CQL.copy(
109-
context.getCqn(), new ActiveEntityModifier(isActiveEntity, entity.getQualifiedName()));
110-
return attachmentsReader.readAttachments(
111-
context.getModel(), (CdsEntity) entity, cqnInactiveEntity);
143+
context.getCqn(),
144+
new ModifierToCreateFlatCQN(isActiveEntity, entity.getQualifiedName()));
145+
logger.debug("Modified CQN: {}", modifiedCQN);
146+
return attachmentsReader.readAttachments(context.getModel(), (CdsEntity) entity, modifiedCQN);
112147
}
113148

114149
private List<Attachments> getCondensedActiveAttachments(
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/*
2+
* © 2024-2025 SAP SE or an SAP affiliate company and cds-feature-attachments contributors.
3+
*/
4+
package com.sap.cds.feature.attachments.handler.draftservice;
5+
6+
import com.sap.cds.ql.CQL;
7+
import com.sap.cds.ql.RefBuilder;
8+
import com.sap.cds.ql.RefBuilder.RefSegment;
9+
import com.sap.cds.ql.StructuredTypeRef;
10+
import com.sap.cds.ql.Value;
11+
import com.sap.cds.ql.cqn.CqnComparisonPredicate.Operator;
12+
import com.sap.cds.ql.cqn.CqnPredicate;
13+
import com.sap.cds.ql.cqn.CqnStructuredTypeRef;
14+
import com.sap.cds.ql.cqn.Modifier;
15+
import com.sap.cds.services.draft.Drafts;
16+
import org.slf4j.Logger;
17+
import org.slf4j.LoggerFactory;
18+
19+
/**
20+
* A CQL modifier that transforms entity references for draft/active entity handling.
21+
*
22+
* <p>This modifier flattens complex entity references by removing nested references and creating a
23+
* new CQN statement for the specified {@code fullEntityName}. It performs the following
24+
* transformations:
25+
*
26+
* <ul>
27+
* <li>Removes nested references and creates a new entity reference for {@code fullEntityName}
28+
* <li>Preserves the filter from the last segment of the original {@link CqnStructuredTypeRef}
29+
* <li>Adds an {@code IsActiveEntity} filter with the specified boolean value
30+
* </ul>
31+
*
32+
* <p>This is primarily used in draft service scenarios to transform queries between draft entities
33+
* (IsActiveEntity = false) and active entities (IsActiveEntity = true).
34+
*/
35+
class ModifierToCreateFlatCQN implements Modifier {
36+
37+
private static final Logger logger = LoggerFactory.getLogger(ModifierToCreateFlatCQN.class);
38+
39+
private final boolean isActiveEntity;
40+
private final String fullEntityName;
41+
42+
ModifierToCreateFlatCQN(boolean isActiveEntity, String fullEntityName) {
43+
this.isActiveEntity = isActiveEntity;
44+
this.fullEntityName = fullEntityName;
45+
}
46+
47+
@Override
48+
public CqnStructuredTypeRef ref(CqnStructuredTypeRef original) {
49+
RefBuilder<StructuredTypeRef> ref = CQL.copy(original);
50+
RefSegment rootSegment = ref.rootSegment();
51+
logger.debug(
52+
"Modifying ref {} with isActiveEntity: {} and fullEntityName: {}",
53+
rootSegment,
54+
isActiveEntity,
55+
fullEntityName);
56+
57+
// Get the filter from the last segment:
58+
// Get the last segment with targetSegment, then an Optional<CqnPredicate> with filter()
59+
// which is then unwrapped to CqnPredicate or null by orElse(null).
60+
CqnPredicate lastSegmentFilter = original.targetSegment().filter().orElse(null);
61+
62+
// Create an IsActiveEntity filter
63+
CqnPredicate isActiveEntityFilter = CQL.get(Drafts.IS_ACTIVE_ENTITY).eq(isActiveEntity);
64+
65+
// Combine with original filter if it exists
66+
CqnPredicate combinedFilter =
67+
lastSegmentFilter != null
68+
? CQL.and(lastSegmentFilter, isActiveEntityFilter)
69+
: isActiveEntityFilter;
70+
71+
// Apply any additional modifications (like replacing other IsActiveEntity references)
72+
// This calls the comparison() method below for each comparison in the filter
73+
CqnPredicate modifiedFilter = CQL.copy(combinedFilter, this);
74+
75+
// Create a new entity reference with the modified filter
76+
return CQL.entity(fullEntityName).filter(modifiedFilter).asRef();
77+
}
78+
79+
@Override
80+
public CqnPredicate comparison(Value<?> lhs, Operator op, Value<?> rhs) {
81+
Value<?> rhsNew = rhs;
82+
Value<?> lhsNew = lhs;
83+
if (lhs.isRef() && Drafts.IS_ACTIVE_ENTITY.equals(lhs.asRef().lastSegment())) {
84+
rhsNew = CQL.constant(isActiveEntity);
85+
}
86+
if (rhs.isRef() && Drafts.IS_ACTIVE_ENTITY.equals(rhs.asRef().lastSegment())) {
87+
lhsNew = CQL.constant(isActiveEntity);
88+
}
89+
return CQL.comparison(lhsNew, op, rhsNew);
90+
}
91+
}

cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/configuration/RegistrationTest.java

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,30 @@ void handlersAreRegistered() {
110110

111111
var handlerSize = 8;
112112
verify(configurer, times(handlerSize)).eventHandler(handlerArgumentCaptor.capture());
113-
var handlers = handlerArgumentCaptor.getAllValues();
113+
checkHandlers(handlerArgumentCaptor.getAllValues(), handlerSize);
114+
}
115+
116+
@Test
117+
void handlersAreRegisteredWithoutOutboxService() {
118+
when(serviceCatalog.getService(PersistenceService.class, PersistenceService.DEFAULT_NAME))
119+
.thenReturn(persistenceService);
120+
when(serviceCatalog.getService(AttachmentService.class, AttachmentService.DEFAULT_NAME))
121+
.thenReturn(attachmentService);
122+
when(serviceCatalog.getServices(DraftService.class)).thenReturn(Stream.of(draftService));
123+
when(serviceCatalog.getServices(ApplicationService.class))
124+
.thenReturn(Stream.of(applicationService));
125+
// Return null for OutboxService to test the missing branch
126+
when(serviceCatalog.getService(OutboxService.class, OutboxService.PERSISTENT_UNORDERED_NAME))
127+
.thenReturn(null);
128+
129+
cut.eventHandlers(configurer);
130+
131+
var handlerSize = 8;
132+
verify(configurer, times(handlerSize)).eventHandler(handlerArgumentCaptor.capture());
133+
checkHandlers(handlerArgumentCaptor.getAllValues(), handlerSize);
134+
}
135+
136+
private void checkHandlers(List<EventHandler> handlers, int handlerSize) {
114137
assertThat(handlers).hasSize(handlerSize);
115138
isHandlerForClassIncluded(handlers, DefaultAttachmentsServiceHandler.class);
116139
isHandlerForClassIncluded(handlers, CreateAttachmentsHandler.class);

cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandlerTest.java

Lines changed: 56 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,7 @@
66
import static org.assertj.core.api.Assertions.assertThat;
77
import static org.mockito.ArgumentMatchers.any;
88
import static org.mockito.ArgumentMatchers.eq;
9-
import static org.mockito.Mockito.mock;
10-
import static org.mockito.Mockito.verify;
11-
import static org.mockito.Mockito.verifyNoInteractions;
12-
import static org.mockito.Mockito.when;
9+
import static org.mockito.Mockito.*;
1310

1411
import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments;
1512
import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.testservice.Attachment;
@@ -70,6 +67,24 @@ void whereConditionIncludedNothingHappens() {
7067
verifyNoInteractions(attachmentsReader, deleteContentAttachmentEvent);
7168
}
7269

70+
@Test
71+
void entityHasNoAttachmentsAndIsNotAttachmentEntityNothingHappens() {
72+
// Test the case where isAttachmentEntity and hasAttachmentAssociations both return false
73+
CdsEntity mockEntity = mock(CdsEntity.class);
74+
// Entity has no elements with name "attachment"
75+
when(mockEntity.getQualifiedName())
76+
.thenReturn("TestService.RegularEntity"); // No "Attachment" in name
77+
when(eventContext.getTarget()).thenReturn(mockEntity);
78+
79+
CqnDelete mockDelete = mock(CqnDelete.class);
80+
when(mockDelete.where()).thenReturn(Optional.empty());
81+
when(eventContext.getCqn()).thenReturn(mockDelete);
82+
83+
cut.processBeforeDraftCancel(eventContext);
84+
85+
verifyNoInteractions(attachmentsReader);
86+
}
87+
7388
@Test
7489
void nothingSelectedNothingToDo() {
7590
getEntityAndMockContext(RootTable_.CDS_NAME);
@@ -84,6 +99,33 @@ void nothingSelectedNothingToDo() {
8499

85100
@Test
86101
void attachmentReaderCorrectCalled() {
102+
getEntityAndMockContext(Attachment_.CDS_NAME);
103+
CqnDelete delete = Delete.from(Attachment_.class);
104+
when(eventContext.getCqn()).thenReturn(delete);
105+
when(eventContext.getModel()).thenReturn(runtime.getCdsModel());
106+
107+
cut.processBeforeDraftCancel(eventContext);
108+
109+
CdsEntity target = eventContext.getTarget();
110+
verify(attachmentsReader)
111+
.readAttachments(eq(runtime.getCdsModel()), eq(target), deleteArgumentCaptor.capture());
112+
// Check if the modified CqnDelete that is passed to readAttachments looks correct
113+
CqnDelete modifiedCQN = deleteArgumentCaptor.getValue();
114+
assertThat(modifiedCQN.toJson())
115+
.isEqualTo(
116+
"{\"DELETE\":{\"from\":{\"ref\":[{\"id\":\"unit.test.TestService.Attachment\",\"where\":[{\"ref\":[\"IsActiveEntity\"]},\"=\",{\"val\":true}]}]}}}");
117+
118+
deleteArgumentCaptor = ArgumentCaptor.forClass(CqnDelete.class);
119+
CdsEntity siblingTarget = target.getTargetOf(Drafts.SIBLING_ENTITY);
120+
verify(attachmentsReader)
121+
.readAttachments(
122+
eq(runtime.getCdsModel()), eq(siblingTarget), deleteArgumentCaptor.capture());
123+
CqnDelete siblingDelete = deleteArgumentCaptor.getValue();
124+
assertThat(siblingDelete.toJson()).isNotEqualTo(delete.toJson());
125+
}
126+
127+
@Test
128+
void attachmentReaderCorrectCalledForEntityWithAttachmentAssociations() {
87129
getEntityAndMockContext(RootTable_.CDS_NAME);
88130
CqnDelete delete = Delete.from(RootTable_.class);
89131
when(eventContext.getCqn()).thenReturn(delete);
@@ -94,8 +136,11 @@ void attachmentReaderCorrectCalled() {
94136
CdsEntity target = eventContext.getTarget();
95137
verify(attachmentsReader)
96138
.readAttachments(eq(runtime.getCdsModel()), eq(target), deleteArgumentCaptor.capture());
97-
CqnDelete originDelete = deleteArgumentCaptor.getValue();
98-
assertThat(originDelete.toJson()).isEqualTo(delete.toJson());
139+
// Check if the modified CqnDelete that is passed to readAttachments looks correct
140+
CqnDelete modifiedCQN = deleteArgumentCaptor.getValue();
141+
assertThat(modifiedCQN.toJson())
142+
.isEqualTo(
143+
"{\"DELETE\":{\"from\":{\"ref\":[{\"id\":\"unit.test.TestService.RootTable\",\"where\":[{\"ref\":[\"IsActiveEntity\"]},\"=\",{\"val\":true}]}]}}}");
99144

100145
deleteArgumentCaptor = ArgumentCaptor.forClass(CqnDelete.class);
101146
CdsEntity siblingTarget = target.getTargetOf(Drafts.SIBLING_ENTITY);
@@ -108,8 +153,8 @@ void attachmentReaderCorrectCalled() {
108153

109154
@Test
110155
void modifierCalledWithCorrectEntitiesIfDraftIsInContext() {
111-
getEntityAndMockContext(RootTable_.CDS_NAME + DraftUtils.DRAFT_TABLE_POSTFIX);
112-
CqnDelete delete = Delete.from(RootTable_.class);
156+
getEntityAndMockContext(Attachment_.CDS_NAME + DraftUtils.DRAFT_TABLE_POSTFIX);
157+
CqnDelete delete = Delete.from(Attachment_.class);
113158
when(eventContext.getCqn()).thenReturn(delete);
114159
when(eventContext.getModel()).thenReturn(runtime.getCdsModel());
115160

@@ -123,7 +168,9 @@ void modifierCalledWithCorrectEntitiesIfDraftIsInContext() {
123168
.readAttachments(
124169
eq(runtime.getCdsModel()), eq(siblingTarget), deleteArgumentCaptor.capture());
125170
CqnDelete siblingDelete = deleteArgumentCaptor.getValue();
126-
assertThat(siblingDelete.toJson()).isEqualTo(delete.toJson());
171+
assertThat(siblingDelete.toJson())
172+
.isEqualTo(
173+
"{\"DELETE\":{\"from\":{\"ref\":[{\"id\":\"unit.test.TestService.Attachment\",\"where\":[{\"ref\":[\"IsActiveEntity\"]},\"=\",{\"val\":true}]}]}}}");
127174
}
128175

129176
@Test

0 commit comments

Comments
 (0)