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
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
sdm/target/
**/target/
.flattened-pom.xml
node_modules/
**/package-lock.json
**/src/gen/

# Compiled class file
*.class
Expand All @@ -22,6 +25,12 @@ node_modules/
*.zip
*.tar.gz
*.rar
*.hdbtable
*.hdbview
*.xml
*.json
*.sql
*.mtar

# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
hs_err_pid*
Expand Down
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
</developers>

<properties>
<revision>1.8.1-SNAPSHOT</revision>
<revision>1.0.0-RC1</revision>
<java.version>21</java.version>
<maven.compiler.source>${java.version}</maven.compiler.source>
<maven.compiler.target>${java.version}</maven.compiler.target>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,20 +62,30 @@ public static List<String> getAttachmentEntityPaths(
}

/**
* Creates a mapping of attachment entity paths to their corresponding actual paths within the CDS
* model.
* Creates a mapping of direct attachment entity paths for the given CDS entity.
*
* <p>This method analyzes both direct and nested attachment compositions within the given entity.
* It processes direct attachments that are immediate compositions of the entity, and also
* traverses nested compositions to find attachments in related entities. The resulting mapping
* provides a translation between logical attachment paths and their actual implementation paths.
* <p>This method processes only direct attachment compositions (immediate compositions of the
* entity that target sap.attachments.Attachments). Nested compositions are not traversed.
*
* @param model the CDS model containing entity definitions and relationships
* @param entity the target CDS entity to analyze for attachment path mappings
* @param persistenceService the persistence service used for data access operations
* @return a map where keys are attachment entity paths and values are the corresponding actual
* paths, or an empty map if no attachments are found or if an error occurs during processing
* @param entity the target CDS entity to analyze for direct attachment path mappings
* @return a map where keys and values are the direct attachment entity paths, or an empty map if
* no direct attachments are found
*/
public static Map<String, String> getDirectAttachmentPathMapping(CdsEntity entity) {
logger.debug(
"Getting direct attachment path mapping for entity: {}", entity.getQualifiedName());
Map<String, String> pathMapping = new HashMap<>();
entity
.compositions()
.forEach(
composition -> processDirectAttachmentComposition(entity, pathMapping, composition));
logger.debug(
"Found {} direct attachment path mappings for entity: {}",
pathMapping.size(),
entity.getQualifiedName());
return pathMapping;
}

public static Map<String, String> getAttachmentPathMapping(
CdsModel model, CdsEntity entity, PersistenceService persistenceService) {
logger.debug("Getting attachment path mapping for entity: {}", entity.getQualifiedName());
Expand Down
48 changes: 26 additions & 22 deletions sdm/src/main/java/com/sap/cds/sdm/persistence/DBQuery.java
Original file line number Diff line number Diff line change
Expand Up @@ -568,37 +568,41 @@ public CmisDocument getuploadStatusForAttachment(
String objectId,
AttachmentReadEventContext context) {
logger.debug("Fetching uploadStatus for objectId: {} from entity: {}", objectId, entity);
Optional<CdsEntity> attachmentEntity = context.getModel().findEntity(entity + "_drafts");
CqnSelect q =
Select.from(attachmentEntity.get())
.columns("uploadStatus")
.where(doc -> doc.get("objectId").eq(objectId));
Result result = persistenceService.run(q);
CmisDocument cmisDocument = new CmisDocument();
boolean isAttachmentFound = false;
for (Row row : result.list()) {
cmisDocument.setUploadStatus(
row.get("uploadStatus") != null
? row.get("uploadStatus").toString()
: SDMConstants.UPLOAD_STATUS_IN_PROGRESS);
isAttachmentFound = true;
}
if (!isAttachmentFound) {
logger.debug(
"Attachment not found in draft table for objectId: {}, checking active entity: {}",
objectId,
entity);
attachmentEntity = context.getModel().findEntity(entity);
q =
Select.from(attachmentEntity.get())
Optional<CdsEntity> draftEntityOpt = context.getModel().findEntity(entity + "_drafts");
if (draftEntityOpt.isPresent()) {
CqnSelect q =
Select.from(draftEntityOpt.get())
.columns("uploadStatus")
.where(doc -> doc.get("objectId").eq(objectId));
result = persistenceService.run(q);
Result result = persistenceService.run(q);
for (Row row : result.list()) {
cmisDocument.setUploadStatus(
row.get("uploadStatus") != null
? row.get("uploadStatus").toString()
: SDMConstants.UPLOAD_STATUS_IN_PROGRESS);
isAttachmentFound = true;
}
}
if (!isAttachmentFound) {
logger.debug(
"Attachment not found in draft table for objectId: {}, checking active entity: {}",
objectId,
entity);
Optional<CdsEntity> activeEntityOpt = context.getModel().findEntity(entity);
if (activeEntityOpt.isPresent()) {
CqnSelect q =
Select.from(activeEntityOpt.get())
.columns("uploadStatus")
.where(doc -> doc.get("objectId").eq(objectId));
Result result = persistenceService.run(q);
for (Row row : result.list()) {
cmisDocument.setUploadStatus(
row.get("uploadStatus") != null
? row.get("uploadStatus").toString()
: SDMConstants.UPLOAD_STATUS_IN_PROGRESS);
}
}
}
logger.debug(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -187,18 +187,13 @@ public void handleDraftDiscardForLinks(DraftCancelEventContext context) throws I
logger.debug("Processing draft cancel for entity: {}", parentEntityName);

Optional<CdsEntity> parentActiveEntityOpt = context.getModel().findEntity(parentEntityName);
Map<String, String> compositionPathMapping =
parentActiveEntityOpt
.map(
cdsEntity ->
AttachmentsHandlerUtils.getAttachmentPathMapping(
context.getModel(), cdsEntity, persistenceService))
.orElse(new HashMap<>());

logger.debug("Found {} composition paths to process", compositionPathMapping.size());
for (Map.Entry<String, String> entry : compositionPathMapping.entrySet()) {
String attachmentCompositionDefinition = entry.getKey();
revertLinksForComposition(context, parentKeys, attachmentCompositionDefinition);
if (parentActiveEntityOpt.isPresent()) {
Map<String, String> compositionPathMapping =
AttachmentsHandlerUtils.getDirectAttachmentPathMapping(parentActiveEntityOpt.get());
logger.debug("Found {} composition paths to process", compositionPathMapping.size());
for (Map.Entry<String, String> entry : compositionPathMapping.entrySet()) {
revertLinksForComposition(context, parentKeys, entry.getKey());
}
}
revertNestedEntityLinks(context);
logger.debug("END: Handle draft discard for links");
Expand Down Expand Up @@ -697,7 +692,13 @@ private void checkAttachmentConstraints(
throws ServiceException {
logger.debug("Checking attachment constraints for upID: {}", upID);
CdsModel cdsModel = context.getModel();
CdsEntity attachmentEntity = cdsModel.findEntity(context.getTarget().getQualifiedName()).get();
CdsEntity attachmentEntity =
cdsModel
.findEntity(context.getTarget().getQualifiedName())
.orElseThrow(
() ->
new ServiceException(
"Entity not found in model: " + context.getTarget().getQualifiedName()));

// Fetch the row count for current repository
Result result =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1797,6 +1797,211 @@ void testHandleDraftDiscardForLinks_CallsRevertNestedEntityLinks() throws IOExce
}
}

@Test
void testHandleDraftDiscardForLinks_OnlyDirectAttachmentsProcessedViaPathMapping()
throws IOException {
// Verifies that handleDraftDiscardForLinks uses getDirectAttachmentPathMapping (not
// getAttachmentPathMapping) on the root entity — i.e. only direct attachments on the root
// are processed via Path 1. Nested attachments are handled exclusively by
// revertNestedEntityLinks.
DraftCancelEventContext draftContext = mock(DraftCancelEventContext.class);
CdsEntity parentDraftEntity = mock(CdsEntity.class);
CqnAnalyzer analyzer = mock(CqnAnalyzer.class);
AnalysisResult analysisResult = mock(AnalysisResult.class);
CqnDelete cqnDelete = mock(CqnDelete.class);
CdsEntity parentActiveEntity = mock(CdsEntity.class);

when(draftContext.getTarget()).thenReturn(parentDraftEntity);
when(parentDraftEntity.getQualifiedName()).thenReturn("AdminService.Books_drafts");
when(draftContext.getModel()).thenReturn(cdsModel);
when(draftContext.getCqn()).thenReturn(cqnDelete);
cqnAnalyzerMock.when(() -> CqnAnalyzer.create(cdsModel)).thenReturn(analyzer);
when(analyzer.analyze(cqnDelete)).thenReturn(analysisResult);
when(analysisResult.rootKeys()).thenReturn(Map.of("ID", "book123"));
when(cdsModel.findEntity("AdminService.Books")).thenReturn(Optional.of(parentActiveEntity));
when(parentActiveEntity.compositions()).thenReturn(Stream.empty());

try (var attachmentUtilsMock =
mockStatic(
com.sap.cds.sdm.handler.applicationservice.helper.AttachmentsHandlerUtils.class)) {

attachmentUtilsMock
.when(
() -> AttachmentsHandlerUtils.getDirectAttachmentPathMapping(eq(parentActiveEntity)))
.thenReturn(new HashMap<>());

sdmServiceGenericHandler.handleDraftDiscardForLinks(draftContext);

// getDirectAttachmentPathMapping must be called on the root entity
attachmentUtilsMock.verify(
() -> AttachmentsHandlerUtils.getDirectAttachmentPathMapping(eq(parentActiveEntity)),
times(1));

// getAttachmentPathMapping must NOT be called on the root entity
attachmentUtilsMock.verify(
() ->
AttachmentsHandlerUtils.getAttachmentPathMapping(
eq(cdsModel), eq(parentActiveEntity), any()),
never());
}
}

@Test
void testHandleDraftDiscardForLinks_DirectAttachmentOnRootIsReverted() throws IOException {
// Verifies that when the root entity has a direct attachment composition,
// revertLinksForComposition is called for it via Path 1 (getDirectAttachmentPathMapping).
DraftCancelEventContext draftContext = mock(DraftCancelEventContext.class);
CdsEntity parentDraftEntity = mock(CdsEntity.class);
CqnAnalyzer analyzer = mock(CqnAnalyzer.class);
AnalysisResult analysisResult = mock(AnalysisResult.class);
CqnDelete cqnDelete = mock(CqnDelete.class);
CdsEntity parentActiveEntity = mock(CdsEntity.class);
CdsEntity attachmentDraftEntity = mock(CdsEntity.class);
CdsEntity attachmentActiveEntity = mock(CdsEntity.class);

when(draftContext.getTarget()).thenReturn(parentDraftEntity);
when(parentDraftEntity.getQualifiedName()).thenReturn("AdminService.Books_drafts");
when(draftContext.getModel()).thenReturn(cdsModel);
when(draftContext.getCqn()).thenReturn(cqnDelete);
cqnAnalyzerMock.when(() -> CqnAnalyzer.create(cdsModel)).thenReturn(analyzer);
when(analyzer.analyze(cqnDelete)).thenReturn(analysisResult);
when(analysisResult.rootKeys()).thenReturn(Map.of("ID", "book123"));
when(cdsModel.findEntity("AdminService.Books")).thenReturn(Optional.of(parentActiveEntity));
when(parentActiveEntity.compositions()).thenReturn(Stream.empty());

// Direct attachment on root
Map<String, String> directMapping = new HashMap<>();
directMapping.put("AdminService.Books.attachments", "AdminService.Books.attachments");

when(cdsModel.findEntity("AdminService.Books.attachments_drafts"))
.thenReturn(Optional.of(attachmentDraftEntity));
when(cdsModel.findEntity("AdminService.Books.attachments"))
.thenReturn(Optional.of(attachmentActiveEntity));

CdsElement upElement = mock(CdsElement.class);
when(attachmentDraftEntity.elements()).thenReturn(Stream.of(upElement));
when(upElement.getName()).thenReturn("up__ID");
sdmUtilsMock.when(() -> SDMUtils.getUpIdKey(attachmentDraftEntity)).thenReturn("up__ID");

Result emptyResult = mock(Result.class);
when(emptyResult.iterator()).thenReturn(Collections.emptyIterator());
when(persistenceService.run(any(CqnSelect.class))).thenReturn(emptyResult);

SDMCredentials sdmCredentials = mock(SDMCredentials.class);
UserInfo userInfo = mock(UserInfo.class);
when(tokenHandler.getSDMCredentials()).thenReturn(sdmCredentials);
when(draftContext.getUserInfo()).thenReturn(userInfo);
when(userInfo.isSystemUser()).thenReturn(false);

try (var attachmentUtilsMock =
mockStatic(
com.sap.cds.sdm.handler.applicationservice.helper.AttachmentsHandlerUtils.class)) {
attachmentUtilsMock
.when(
() -> AttachmentsHandlerUtils.getDirectAttachmentPathMapping(eq(parentActiveEntity)))
.thenReturn(directMapping);

assertDoesNotThrow(() -> sdmServiceGenericHandler.handleDraftDiscardForLinks(draftContext));

// persistence was called — confirming revertLinksForComposition was entered for the direct
// attachment
verify(persistenceService, atLeastOnce()).run(any(CqnSelect.class));
}
}

@Test
void testHandleDraftDiscardForLinks_GrandchildAttachmentDoesNotCrash() throws IOException {
// Regression test for the bug: root → Chapters (no attachments) → Sections (has attachments).
// With the old code, getAttachmentPathMapping on root would construct a wrong entity name
// "AdminService.Chapters.attachments" causing NoSuchElementException.
// With the fix, getDirectAttachmentPathMapping returns empty for root (no direct attachments),
// and revertNestedEntityLinks correctly handles Sections via Chapters.
DraftCancelEventContext draftContext = mock(DraftCancelEventContext.class);
CdsEntity parentDraftEntity = mock(CdsEntity.class);
CqnAnalyzer analyzer = mock(CqnAnalyzer.class);
AnalysisResult analysisResult = mock(AnalysisResult.class);
CqnDelete cqnDelete = mock(CqnDelete.class);
CdsEntity parentActiveEntity = mock(CdsEntity.class);
CdsElement chaptersComposition = mock(CdsElement.class);
CdsAssociationType chaptersAssocType = mock(CdsAssociationType.class);
CdsEntity chaptersEntity = mock(CdsEntity.class);

when(draftContext.getTarget()).thenReturn(parentDraftEntity);
when(parentDraftEntity.getQualifiedName()).thenReturn("AdminService.Books_drafts");
when(draftContext.getModel()).thenReturn(cdsModel);
when(draftContext.getCqn()).thenReturn(cqnDelete);
cqnAnalyzerMock.when(() -> CqnAnalyzer.create(cdsModel)).thenReturn(analyzer);
when(analyzer.analyze(cqnDelete)).thenReturn(analysisResult);
when(analysisResult.rootKeys()).thenReturn(Map.of("ID", "book123"));
when(cdsModel.findEntity("AdminService.Books")).thenReturn(Optional.of(parentActiveEntity));

// Root has one composition: Chapters (no direct attachments)
when(parentActiveEntity.compositions()).thenReturn(Stream.of(chaptersComposition));
when(chaptersComposition.getType()).thenReturn(chaptersAssocType);
when(chaptersAssocType.getTarget()).thenReturn(chaptersEntity);
when(chaptersEntity.getQualifiedName()).thenReturn("AdminService.Chapters");

// Chapters_drafts does not exist (simulates non-draft-enabled or grandchild scenario)
when(cdsModel.findEntity("AdminService.Chapters_drafts")).thenReturn(Optional.empty());

try (var attachmentUtilsMock =
mockStatic(
com.sap.cds.sdm.handler.applicationservice.helper.AttachmentsHandlerUtils.class)) {

// Root has NO direct attachments
attachmentUtilsMock
.when(
() -> AttachmentsHandlerUtils.getDirectAttachmentPathMapping(eq(parentActiveEntity)))
.thenReturn(new HashMap<>());

// Must not throw NoSuchElementException
assertDoesNotThrow(() -> sdmServiceGenericHandler.handleDraftDiscardForLinks(draftContext));

// getDirectAttachmentPathMapping called on root — not getAttachmentPathMapping
attachmentUtilsMock.verify(
() -> AttachmentsHandlerUtils.getDirectAttachmentPathMapping(eq(parentActiveEntity)),
times(1));
attachmentUtilsMock.verify(
() ->
AttachmentsHandlerUtils.getAttachmentPathMapping(
eq(cdsModel), eq(parentActiveEntity), any()),
never());
}
}

@Test
void testHandleDraftDiscardForLinks_ActiveEntityNotFound_SkipsDirectAttachments()
throws IOException {
// When the active entity is not found in the model, no attachment processing should occur.
DraftCancelEventContext draftContext = mock(DraftCancelEventContext.class);
CdsEntity parentDraftEntity = mock(CdsEntity.class);
CqnAnalyzer analyzer = mock(CqnAnalyzer.class);
AnalysisResult analysisResult = mock(AnalysisResult.class);
CqnDelete cqnDelete = mock(CqnDelete.class);

when(draftContext.getTarget()).thenReturn(parentDraftEntity);
when(parentDraftEntity.getQualifiedName()).thenReturn("AdminService.Books_drafts");
when(draftContext.getModel()).thenReturn(cdsModel);
when(draftContext.getCqn()).thenReturn(cqnDelete);
cqnAnalyzerMock.when(() -> CqnAnalyzer.create(cdsModel)).thenReturn(analyzer);
when(analyzer.analyze(cqnDelete)).thenReturn(analysisResult);
when(analysisResult.rootKeys()).thenReturn(Map.of("ID", "book123"));
when(cdsModel.findEntity("AdminService.Books")).thenReturn(Optional.empty());

try (var attachmentUtilsMock =
mockStatic(
com.sap.cds.sdm.handler.applicationservice.helper.AttachmentsHandlerUtils.class)) {

assertDoesNotThrow(() -> sdmServiceGenericHandler.handleDraftDiscardForLinks(draftContext));

// Neither method should be called since active entity is absent
attachmentUtilsMock.verify(
() -> AttachmentsHandlerUtils.getDirectAttachmentPathMapping(any()), never());
attachmentUtilsMock.verify(
() -> AttachmentsHandlerUtils.getAttachmentPathMapping(any(), any(), any()), never());
}
}

@Test
void testRevertNestedEntityLinks_WithNullParentId() throws IOException {

Expand Down
Loading