Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/single-tenant/personal-space/demoapp/db/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
<dependency>
<groupId>com.sap.cds</groupId>
<artifactId>sdm</artifactId>
<version>1.0.0-RC1</version>
<version>1.10.0</version>
</dependency>
</dependencies>

Expand Down
8 changes: 7 additions & 1 deletion app/single-tenant/personal-space/demoapp/srv/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
<dependency>
<groupId>com.sap.cds</groupId>
<artifactId>sdm</artifactId>
<version>1.0.0-RC1</version>
<version>1.10.0</version>
</dependency>


Expand Down Expand Up @@ -47,6 +47,12 @@
<scope>test</scope>
</dependency>

<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>

<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,14 @@ spring:
schema-locations: classpath:schema-h2.sql
data-source:
auto-config:
enabled: false
enabled: false
cds:
security:
mock.users:
admin:
password: admin
roles:
- admin
- system-user
user:
password: user
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package customer.demoapp.handlers;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;

/**
* Reproduces the SDM 1.10.0 regression:
*
* SDMReadAttachmentsHandler.populateUploadableFlags (new @After handler in 1.10.0) fires after
* every successful read on any entity whose composition has @SDM.Attachments. It then calls
* DBQuery.getAttachmentsForUPID, which unconditionally SELECTs "IsActiveEntity" — a column that
* only exists on draft-enabled entities. CatalogService.Books is a non-draft, @readonly
* projection, so its Books.attachments entity has no IsActiveEntity element. The CQN
* resolution throws CdsElementNotFoundException, which surfaces as HTTP 500.
*
* Expected (broken on SDM 1.10.0 without the fix): GET /odata/v4/CatalogService/Books → 500
* Expected (after the fix): GET /odata/v4/CatalogService/Books → 200
*
* The seed CSV ships 5 books, so the response always has rows — exactly the condition that
* triggers populateUploadableFlags (it returns early on empty data).
*/
@SpringBootTest
@AutoConfigureMockMvc
class CatalogServiceNonDraftReadIT {

// CatalogService is bound at the default CDS OData endpoint path.
// The CAP Spring Boot starter mounts OData services under /odata/v4/<ServiceName>.
private static final String BOOKS_URI = "/odata/v4/CatalogService/Books";

@Autowired
private MockMvc mockMvc;

/**
* A plain, unauthenticated GET on the non-draft CatalogService.Books must return 200
* and at least one book row. On SDM 1.10.0 (unpatched) this returns 500 because
* populateUploadableFlags tries to SELECT IsActiveEntity from a non-draft attachment entity.
*/
@Test
void nonDraftReadOfBooksWithAttachmentsMustReturn200() throws Exception {
mockMvc.perform(get(BOOKS_URI))
.andExpect(status().isOk())
.andExpect(jsonPath("$.value").isArray())
.andExpect(jsonPath("$.value[0].ID").exists());
}

/**
* Same call with $top=1 — verifies it is not the 0-row early-exit case that happens to pass.
* populateUploadableFlags does `if (data == null || data.isEmpty()) return;`, so an empty
* result would never hit the crash. This test ensures at least one row is returned AND
* processed without error.
*/
@Test
void nonDraftReadWithTopOneStillReturns200() throws Exception {
mockMvc.perform(get(BOOKS_URI + "?$top=1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.value").isArray())
.andExpect(jsonPath("$.value[0].ID").exists());
}

/**
* Filtering to a row that is known to exist (Wuthering Heights is in the seed CSV).
* Exercises the exact code path: a query that returns exactly 1 row goes through
* populateUploadableFlags fully, hitting getAttachmentsForUPID once per facet.
*/
@Test
void nonDraftReadWithFilterStillReturns200() throws Exception {
mockMvc.perform(get(BOOKS_URI + "?$filter=stock gt 0"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.value").isArray());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -460,9 +460,15 @@ public void populateUploadableFlags(CdsReadEventContext context, List<CdsData> d
CdsModel model = context.getModel();
// Cache keyed by "facetName|parentId|isDraft" to avoid one DB query per row per facet.
Map<String, Boolean> uploadableCache = new HashMap<>();

// Non-draft entities have no IsActiveEntity; treat every row as active (isDraft=false).
boolean entityHasDraftSupport = target.findElement("IsActiveEntity").isPresent();

for (CdsData row : data) {
// Determine draft state per row — a single result set can mix active and draft records.
boolean rowIsDraft = Boolean.FALSE.equals(row.get("IsActiveEntity"));
// On a non-draft entity IsActiveEntity is absent; default to false (active).
boolean rowIsDraft =
entityHasDraftSupport && Boolean.FALSE.equals(row.get("IsActiveEntity"));
Object keyVal = row.get(keyField);
if (keyVal == null) {
logger.debug("populateUploadableFlags Path1: skipping row with null keyVal");
Expand Down
32 changes: 22 additions & 10 deletions sdm/src/main/java/com/sap/cds/sdm/persistence/DBQuery.java
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,15 @@ public Result getAttachmentsForUPID(
upID,
upIdKey,
attachmentEntity.getQualifiedName());
List<String> columns =
new ArrayList<>(
java.util.Arrays.asList("fileName", "ID", "folderId", "repositoryId", "mimeType"));
if (attachmentEntity.findElement("IsActiveEntity").isPresent()) {
columns.add("IsActiveEntity");
}
CqnSelect q =
Select.from(attachmentEntity)
.columns("fileName", "ID", "IsActiveEntity", "folderId", "repositoryId", "mimeType")
.columns(columns.toArray(new String[0]))
.where(doc -> doc.get(upIdKey).eq(upID));
Result result = persistenceService.run(q);
logger.debug("Found {} attachment(s) for upID: {}", result.rowCount(), upID);
Expand Down Expand Up @@ -378,9 +384,14 @@ public Result getAttachmentsForUPIDAndRepository(
upID,
SDMConstants.REPOSITORY_ID,
attachmentEntity.getQualifiedName());
List<String> columns =
new ArrayList<>(java.util.Arrays.asList("fileName", "ID", "folderId", "repositoryId"));
if (attachmentEntity.findElement("IsActiveEntity").isPresent()) {
columns.add("IsActiveEntity");
}
CqnSelect q =
Select.from(attachmentEntity)
.columns("fileName", "ID", "IsActiveEntity", "folderId", "repositoryId")
.columns(columns.toArray(new String[0]))
.where(
doc ->
doc.get(upIdKey)
Expand Down Expand Up @@ -511,16 +522,17 @@ public List<CmisDocument> getAttachmentsForFolder(
folderId,
entity);
attachmentEntity = context.getModel().findEntity(entity);
List<String> activeColumns =
new ArrayList<>(
java.util.Arrays.asList(
"fileName", "ID", "folderId", "repositoryId", "objectId", "uploadStatus"));
if (attachmentEntity.isPresent()
&& attachmentEntity.get().findElement("IsActiveEntity").isPresent()) {
activeColumns.add(1, "IsActiveEntity");
}
q =
Select.from(attachmentEntity.get())
.columns(
"fileName",
"IsActiveEntity",
"ID",
"folderId",
"repositoryId",
"objectId",
"uploadStatus")
.columns(activeColumns.toArray(new String[0]))
.where(doc -> doc.get("folderId").eq(folderId));
result = persistenceService.run(q);
for (Row row : result.list()) {
Expand Down
Loading