Skip to content

Commit e8d63f6

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 770b43d commit e8d63f6

File tree

5 files changed

+274
-171
lines changed

5 files changed

+274
-171
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,107 @@
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+
* The class is used to modify CQN queries by performing the following operations:
21+
*
22+
* <ul>
23+
* <li>Modifies {@code IsActiveEntity} filter values to match the target active/draft state
24+
* <li>Replaces entity references with the specified {@code fullEntityName}
25+
* <li>Flattens nested references where the root entity name matches the target entity path
26+
* prefix, consolidating multi-segment paths (e.g., Books → covers) into a single entity
27+
* reference (e.g., Books.covers_drafts) while preserving filter conditions from the deepest
28+
* segment
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:
33+
* true}, 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
58+
// makes sense to "flatten the query"
59+
if (ref.segments().size() > 1
60+
&& rootSegment
61+
.id()
62+
.equals(fullEntityName.substring(0, Math.max(0, fullEntityName.lastIndexOf('.'))))) {
63+
logger.debug("Removing nested segments for entity {}", fullEntityName);
64+
65+
// Get the filter from the last segment before removing segments
66+
RefSegment lastSegment = ref.segments().get(ref.segments().size() - 1);
67+
CqnPredicate lastFilter = null;
68+
if (lastSegment.filter().isPresent()) {
69+
Modifier modifier = new ActiveEntityModifierFlattener(isActiveEntity, fullEntityName);
70+
lastFilter = CQL.copy(lastSegment.filter().get(), modifier);
71+
}
72+
73+
// Replace root segment with full entity name and remove nested segments
74+
rootSegment.id(fullEntityName);
75+
while (ref.segments().size() > 1) {
76+
ref.segments().remove(ref.segments().size() - 1);
77+
}
78+
79+
// Apply the filter to the root segment if we had one
80+
if (lastFilter != null) {
81+
rootSegment.filter(lastFilter);
82+
}
83+
} else {
84+
// Set the root segment and apply modifier to all filters
85+
rootSegment.id(fullEntityName);
86+
Modifier modifier = new ActiveEntityModifierFlattener(isActiveEntity, fullEntityName);
87+
for (RefSegment segment : ref.segments()) {
88+
segment.filter(segment.filter().map(filter -> CQL.copy(filter, modifier)).orElse(null));
89+
}
90+
}
91+
92+
return ref.build();
93+
}
94+
95+
@Override
96+
public CqnPredicate comparison(Value<?> lhs, Operator op, Value<?> rhs) {
97+
Value<?> rhsNew = rhs;
98+
Value<?> lhsNew = lhs;
99+
if (lhs.isRef() && Drafts.IS_ACTIVE_ENTITY.equals(lhs.asRef().lastSegment())) {
100+
rhsNew = CQL.constant(isActiveEntity);
101+
}
102+
if (rhs.isRef() && Drafts.IS_ACTIVE_ENTITY.equals(rhs.asRef().lastSegment())) {
103+
lhsNew = CQL.constant(isActiveEntity);
104+
}
105+
return CQL.comparison(lhsNew, op, rhsNew);
106+
}
107+
}

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

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -109,13 +109,12 @@ private boolean isWhereEmpty(DraftCancelEventContext context) {
109109
private List<Attachments> readAttachments(
110110
DraftCancelEventContext context, CdsStructuredType entity, boolean isActiveEntity) {
111111
logger.debug(
112-
"Reading attachments for entity {} (isActiveEntity={})",
113-
entity.getName(),
114-
isActiveEntity);
112+
"Reading attachments for entity {} (isActiveEntity={})", entity.getName(), isActiveEntity);
115113
logger.debug("Original CQN: {}", context.getCqn());
116114
CqnDelete cqnInactiveEntity =
117115
CQL.copy(
118-
context.getCqn(), new ActiveEntityModifier(isActiveEntity, entity.getQualifiedName()));
116+
context.getCqn(),
117+
new ActiveEntityModifierFlattener(isActiveEntity, entity.getQualifiedName()));
119118
logger.debug("Modified CQN: {}", cqnInactiveEntity);
120119
return attachmentsReader.readAttachments(
121120
context.getModel(), (CdsEntity) entity, cqnInactiveEntity);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
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 =
45+
CQL.copy(select, new ActiveEntityModifierFlattener(true, RootTable_.CDS_NAME + "_draft"));
46+
47+
assertThat(result.toString()).contains("{\"ref\":[\"unit.test.TestService.RootTable_draft\"]}");
48+
}
49+
50+
@Test
51+
void nothingReplaced() {
52+
var select = Select.from(RootTable_.class).where(root -> root.IsActiveEntity().eq(true));
53+
54+
var result = CQL.copy(select, new ActiveEntityModifierFlattener(true, RootTable_.CDS_NAME));
55+
56+
assertThat(result).hasToString(select.toString());
57+
}
58+
59+
@Test
60+
void selectWithFilterReplace() {
61+
CqnSelect select =
62+
Select.from(
63+
TEST_DRAFT_SERVICE_BOOKS,
64+
c ->
65+
c.filter(e -> e.get(IS_ACTIVE_ENTITY).eq(false))
66+
.to("relatedMovies")
67+
.filter(e -> e.get(IS_ACTIVE_ENTITY).eq(false))
68+
.to("relatedBook"));
69+
70+
var result =
71+
CQL.copy(select, new ActiveEntityModifierFlattener(true, TEST_DRAFT_SERVICE_BOOKS));
72+
73+
assertThat(result.toString()).contains("{\"ref\":[\"IsActiveEntity\"]},\"=\",{\"val\":true}]}");
74+
assertThat(result.toString()).doesNotContain("false");
75+
}
76+
77+
@Test
78+
void onlyRefActiveEntityIsReplaced() {
79+
var select =
80+
Select.from(RootTable_.class)
81+
.where(
82+
root ->
83+
root.IsActiveEntity()
84+
.eq(true)
85+
.and(
86+
root.HasActiveEntity()
87+
.eq(true)
88+
.and(
89+
CQL.constant(true)
90+
.eq(root.IsActiveEntity())
91+
.and(CQL.constant(true).eq(root.HasActiveEntity())))));
92+
93+
var result = CQL.copy(select, new ActiveEntityModifierFlattener(false, RootTable_.CDS_NAME));
94+
95+
assertThat(result.toString()).contains("{\"ref\":[\"IsActiveEntity\"]},\"=\",{\"val\":false}");
96+
assertThat(result.toString()).contains("{\"ref\":[\"HasActiveEntity\"]},\"=\",{\"val\":true}");
97+
assertThat(result.toString()).contains("{\"val\":false},\"=\",{\"ref\":[\"IsActiveEntity\"]}");
98+
assertThat(result.toString()).contains("{\"val\":true},\"=\",{\"ref\":[\"HasActiveEntity\"]}");
99+
}
100+
101+
@Test
102+
void nestedReferenceWithFilterIsFlattened() {
103+
// Create a nested reference with a filter on the last segment: AdminService.Books -> covers
104+
// (with filter)
105+
CqnSelect select =
106+
Select.from(
107+
"AdminService.Books",
108+
c -> c.to("covers").filter(e -> e.get(IS_ACTIVE_ENTITY).eq(false)));
109+
110+
// The target entity name should be the flattened version
111+
String targetEntityName = "AdminService.Books.covers";
112+
var result = CQL.copy(select, new ActiveEntityModifierFlattener(true, targetEntityName));
113+
114+
// The result should have the flattened entity name
115+
assertThat(result.toString()).contains(targetEntityName);
116+
// The filter should be modified to use the new IsActiveEntity value
117+
assertThat(result.toString()).contains("{\"ref\":[\"IsActiveEntity\"]},\"=\",{\"val\":true}]}");
118+
// Should not contain the original nested structure
119+
assertThat(result.toString()).doesNotContain("\"to\":");
120+
}
121+
122+
@Test
123+
void nestedReferenceWithoutFilterIsFlattened() {
124+
// Create a nested reference with a filter on the last segment: AdminService.Books -> covers
125+
// (with filter)
126+
CqnSelect select = Select.from("AdminService.Books", c -> c.to("covers"));
127+
128+
// The target entity name should be the flattened version
129+
String targetEntityName = "AdminService.Books.covers";
130+
var result = CQL.copy(select, new ActiveEntityModifierFlattener(true, targetEntityName));
131+
132+
// The result should have the flattened entity name
133+
assertThat(result.toString()).contains(targetEntityName);
134+
// Should not contain the original nested structure
135+
assertThat(result.toString()).doesNotContain("\"to\":");
136+
}
137+
138+
@Test
139+
void nestedReferenceWithNonMatchingRootIsNotFlattened() {
140+
// Create a nested reference where root doesn't match: OtherService.Books -> covers
141+
CqnSelect select = Select.from("OtherService.Books", c -> c.to("covers"));
142+
143+
// Target entity is AdminService.Books_covers - root doesn't match
144+
String targetEntityName = "AdminService.Books.covers";
145+
var result = CQL.copy(select, new ActiveEntityModifierFlattener(true, targetEntityName));
146+
147+
// Since the root "OtherService.Books" doesn't match "AdminService.Books" (from
148+
// targetEntityName),
149+
// it should follow the normal path and just replace the root entity name
150+
assertThat(result.toString()).contains(targetEntityName);
151+
}
152+
153+
@Test
154+
void singleSegmentReferenceIsNotFlattened() {
155+
// Test with single segment (no nesting) to ensure else branch is covered
156+
CqnSelect select = Select.from("AdminService.Books");
157+
158+
String targetEntityName = "AdminService.Books_drafts";
159+
var result = CQL.copy(select, new ActiveEntityModifierFlattener(false, targetEntityName));
160+
161+
// Should follow the normal path (else branch) since there's only one segment
162+
assertThat(result.toString()).contains(targetEntityName);
163+
}
164+
}

0 commit comments

Comments
 (0)