Skip to content

Commit 16e0372

Browse files
committed
Change ActiveEntityModifier to ActiveEntityModifierFlattener
This is used in the DraftCancelAttachmentsHandler to construct SELECT queries from a DELETE statement, when "fiori": { "draft_messages": true } then the UI sends refs with multiple segments which we transform to a working SELECT statement with the ActiveEntityModifierFlattener.
1 parent 5c16fbe commit 16e0372

File tree

5 files changed

+262
-168
lines changed

5 files changed

+262
-168
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.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
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 org.slf4j.Logger;
7+
import org.slf4j.LoggerFactory;
8+
9+
import com.sap.cds.ql.CQL;
10+
import com.sap.cds.ql.RefBuilder;
11+
import com.sap.cds.ql.RefBuilder.RefSegment;
12+
import com.sap.cds.ql.StructuredTypeRef;
13+
import com.sap.cds.ql.Value;
14+
import com.sap.cds.ql.cqn.CqnComparisonPredicate.Operator;
15+
import com.sap.cds.ql.cqn.CqnPredicate;
16+
import com.sap.cds.ql.cqn.CqnStructuredTypeRef;
17+
import com.sap.cds.ql.cqn.Modifier;
18+
import com.sap.cds.services.draft.Drafts;
19+
20+
/**
21+
* The class is used to modify CQN queries by performing the following operations:
22+
*
23+
* <ul>
24+
* <li>Modifies {@code IsActiveEntity} filter values to match the target active/draft state</li>
25+
* <li>Replaces entity references with the specified {@code fullEntityName}</li>
26+
* <li>Flattens nested references where the root entity name matches the target entity path prefix,
27+
* consolidating multi-segment paths (e.g., Books → covers) into a single entity reference
28+
* (e.g., Books.covers_drafts) while preserving filter conditions from the deepest segment</li>
29+
* </ul>
30+
*
31+
* This modifier is used in the DraftCancelAttachmentsHandler to transform queries that reference
32+
* composition relationships into direct entity queries, especially when fiori: {draft_messages: true},
33+
* because then the UI sends refs with multiple segments which need to be flattened.
34+
*/
35+
class ActiveEntityModifierFlattener implements Modifier {
36+
37+
private static final Logger logger = LoggerFactory.getLogger(ActiveEntityModifierFlattener.class);
38+
39+
private final boolean isActiveEntity;
40+
private final String fullEntityName;
41+
42+
ActiveEntityModifierFlattener(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+
// A nested reference where the root entity name matches our target entity path - only then it makes sense to "flatten the query"
58+
if (ref.segments().size() > 1 && rootSegment.id().equals(fullEntityName.substring(0, Math.max(0,fullEntityName.lastIndexOf('.'))))) {
59+
logger.debug("Removing nested segments for entity {}", fullEntityName);
60+
61+
// Get the filter from the last segment before removing segments
62+
RefSegment lastSegment = ref.segments().get(ref.segments().size() - 1);
63+
CqnPredicate lastFilter = null;
64+
if (lastSegment.filter().isPresent()) {
65+
Modifier modifier = new ActiveEntityModifierFlattener(isActiveEntity, fullEntityName);
66+
lastFilter = CQL.copy(lastSegment.filter().get(), modifier);
67+
}
68+
69+
// Replace root segment with full entity name and remove nested segments
70+
rootSegment.id(fullEntityName);
71+
while (ref.segments().size() > 1) {
72+
ref.segments().remove(ref.segments().size() - 1);
73+
}
74+
75+
// Apply the filter to the root segment if we had one
76+
if (lastFilter != null) {
77+
rootSegment.filter(lastFilter);
78+
}
79+
} else {
80+
// Set the root segment and apply modifier to all filters
81+
rootSegment.id(fullEntityName);
82+
Modifier modifier = new ActiveEntityModifierFlattener(isActiveEntity, fullEntityName);
83+
for (RefSegment segment : ref.segments()) {
84+
segment.filter(segment.filter().map(filter -> CQL.copy(filter, modifier)).orElse(null));
85+
}
86+
}
87+
88+
return ref.build();
89+
}
90+
91+
@Override
92+
public CqnPredicate comparison(Value<?> lhs, Operator op, Value<?> rhs) {
93+
Value<?> rhsNew = rhs;
94+
Value<?> lhsNew = lhs;
95+
if (lhs.isRef() && Drafts.IS_ACTIVE_ENTITY.equals(lhs.asRef().lastSegment())) {
96+
rhsNew = CQL.constant(isActiveEntity);
97+
}
98+
if (rhs.isRef() && Drafts.IS_ACTIVE_ENTITY.equals(rhs.asRef().lastSegment())) {
99+
lhsNew = CQL.constant(isActiveEntity);
100+
}
101+
return CQL.comparison(lhsNew, op, rhsNew);
102+
}
103+
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ private List<Attachments> readAttachments(
111111
logger.debug("Original CQN: {}", context.getCqn());
112112
CqnDelete cqnInactiveEntity =
113113
CQL.copy(
114-
context.getCqn(), new ActiveEntityModifier(isActiveEntity, entity.getQualifiedName()));
114+
context.getCqn(), new ActiveEntityModifierFlattener(isActiveEntity, entity.getQualifiedName()));
115115
logger.debug("Modified CQN: {}", cqnInactiveEntity);
116116
return attachmentsReader.readAttachments(
117117
context.getModel(), (CdsEntity) entity, cqnInactiveEntity);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
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 static com.sap.cds.services.draft.Drafts.IS_ACTIVE_ENTITY;
7+
import static org.assertj.core.api.Assertions.assertThat;
8+
9+
import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.testservice.RootTable_;
10+
import com.sap.cds.ql.CQL;
11+
import com.sap.cds.ql.Select;
12+
import com.sap.cds.ql.cqn.CqnSelect;
13+
import org.junit.jupiter.api.Test;
14+
15+
class ActiveEntityModifierFlattenerTest {
16+
17+
private static final String TEST_DRAFT_SERVICE_BOOKS = "test.DraftService.Books";
18+
19+
@Test
20+
void activeEntityReplacedToFalse() {
21+
var select = Select.from(RootTable_.class).where(root -> root.IsActiveEntity().eq(true));
22+
23+
var result = CQL.copy(select, new ActiveEntityModifierFlattener(false, RootTable_.CDS_NAME));
24+
25+
assertThat(result.toString())
26+
.contains("{\"ref\":[\"IsActiveEntity\"]},\"=\",{\"val\":false}]}");
27+
assertThat(result.toString()).doesNotContain("true");
28+
}
29+
30+
@Test
31+
void activeEntityReplacedToTrue() {
32+
var select = Select.from(RootTable_.class).where(root -> root.IsActiveEntity().eq(false));
33+
34+
var result = CQL.copy(select, new ActiveEntityModifierFlattener(true, RootTable_.CDS_NAME));
35+
36+
assertThat(result.toString()).contains("{\"ref\":[\"IsActiveEntity\"]},\"=\",{\"val\":true}]}");
37+
assertThat(result.toString()).doesNotContain("false");
38+
}
39+
40+
@Test
41+
void entityNameReplaced() {
42+
var select = Select.from(RootTable_.class).where(root -> root.IsActiveEntity().eq(true));
43+
44+
var result = CQL.copy(select, new ActiveEntityModifierFlattener(true, RootTable_.CDS_NAME + "_draft"));
45+
46+
assertThat(result.toString()).contains("{\"ref\":[\"unit.test.TestService.RootTable_draft\"]}");
47+
}
48+
49+
@Test
50+
void nothingReplaced() {
51+
var select = Select.from(RootTable_.class).where(root -> root.IsActiveEntity().eq(true));
52+
53+
var result = CQL.copy(select, new ActiveEntityModifierFlattener(true, RootTable_.CDS_NAME));
54+
55+
assertThat(result).hasToString(select.toString());
56+
}
57+
58+
@Test
59+
void selectWithFilterReplace() {
60+
CqnSelect select =
61+
Select.from(
62+
TEST_DRAFT_SERVICE_BOOKS,
63+
c ->
64+
c.filter(e -> e.get(IS_ACTIVE_ENTITY).eq(false))
65+
.to("relatedMovies")
66+
.filter(e -> e.get(IS_ACTIVE_ENTITY).eq(false))
67+
.to("relatedBook"));
68+
69+
var result = CQL.copy(select, new ActiveEntityModifierFlattener(true, TEST_DRAFT_SERVICE_BOOKS));
70+
71+
assertThat(result.toString()).contains("{\"ref\":[\"IsActiveEntity\"]},\"=\",{\"val\":true}]}");
72+
assertThat(result.toString()).doesNotContain("false");
73+
}
74+
75+
@Test
76+
void onlyRefActiveEntityIsReplaced() {
77+
var select =
78+
Select.from(RootTable_.class)
79+
.where(
80+
root ->
81+
root.IsActiveEntity()
82+
.eq(true)
83+
.and(
84+
root.HasActiveEntity()
85+
.eq(true)
86+
.and(
87+
CQL.constant(true)
88+
.eq(root.IsActiveEntity())
89+
.and(CQL.constant(true).eq(root.HasActiveEntity())))));
90+
91+
var result = CQL.copy(select, new ActiveEntityModifierFlattener(false, RootTable_.CDS_NAME));
92+
93+
assertThat(result.toString()).contains("{\"ref\":[\"IsActiveEntity\"]},\"=\",{\"val\":false}");
94+
assertThat(result.toString()).contains("{\"ref\":[\"HasActiveEntity\"]},\"=\",{\"val\":true}");
95+
assertThat(result.toString()).contains("{\"val\":false},\"=\",{\"ref\":[\"IsActiveEntity\"]}");
96+
assertThat(result.toString()).contains("{\"val\":true},\"=\",{\"ref\":[\"HasActiveEntity\"]}");
97+
}
98+
99+
@Test
100+
void nestedReferenceWithFilterIsFlattened() {
101+
// Create a nested reference with a filter on the last segment: AdminService.Books -> covers (with filter)
102+
CqnSelect select = Select.from("AdminService.Books",
103+
c -> c.to("covers").filter(e -> e.get(IS_ACTIVE_ENTITY).eq(false)));
104+
105+
// The target entity name should be the flattened version
106+
String targetEntityName = "AdminService.Books.covers";
107+
var result = CQL.copy(select, new ActiveEntityModifierFlattener(true, targetEntityName));
108+
109+
// The result should have the flattened entity name
110+
assertThat(result.toString()).contains(targetEntityName);
111+
// The filter should be modified to use the new IsActiveEntity value
112+
assertThat(result.toString()).contains("{\"ref\":[\"IsActiveEntity\"]},\"=\",{\"val\":true}]}");
113+
// Should not contain the original nested structure
114+
assertThat(result.toString()).doesNotContain("\"to\":");
115+
}
116+
117+
@Test
118+
void nestedReferenceWithoutFilterIsFlattened() {
119+
// Create a nested reference with a filter on the last segment: AdminService.Books -> covers (with filter)
120+
CqnSelect select = Select.from("AdminService.Books",
121+
c -> c.to("covers"));
122+
123+
// The target entity name should be the flattened version
124+
String targetEntityName = "AdminService.Books.covers";
125+
var result = CQL.copy(select, new ActiveEntityModifierFlattener(true, targetEntityName));
126+
127+
// The result should have the flattened entity name
128+
assertThat(result.toString()).contains(targetEntityName);
129+
// Should not contain the original nested structure
130+
assertThat(result.toString()).doesNotContain("\"to\":");
131+
}
132+
133+
@Test
134+
void nestedReferenceWithNonMatchingRootIsNotFlattened() {
135+
// Create a nested reference where root doesn't match: OtherService.Books -> covers
136+
CqnSelect select = Select.from("OtherService.Books", c -> c.to("covers"));
137+
138+
// Target entity is AdminService.Books_covers - root doesn't match
139+
String targetEntityName = "AdminService.Books.covers";
140+
var result = CQL.copy(select, new ActiveEntityModifierFlattener(true, targetEntityName));
141+
142+
// Since the root "OtherService.Books" doesn't match "AdminService.Books" (from targetEntityName),
143+
// it should follow the normal path and just replace the root entity name
144+
assertThat(result.toString()).contains(targetEntityName);
145+
}
146+
147+
@Test
148+
void singleSegmentReferenceIsNotFlattened() {
149+
// Test with single segment (no nesting) to ensure else branch is covered
150+
CqnSelect select = Select.from("AdminService.Books");
151+
152+
String targetEntityName = "AdminService.Books_drafts";
153+
var result = CQL.copy(select, new ActiveEntityModifierFlattener(false, targetEntityName));
154+
155+
// Should follow the normal path (else branch) since there's only one segment
156+
assertThat(result.toString()).contains(targetEntityName);
157+
}
158+
}

0 commit comments

Comments
 (0)