diff --git a/sdm/src/main/java/com/sap/cds/sdm/constants/SDMConstants.java b/sdm/src/main/java/com/sap/cds/sdm/constants/SDMConstants.java index b5915cc0..bf737cda 100644 --- a/sdm/src/main/java/com/sap/cds/sdm/constants/SDMConstants.java +++ b/sdm/src/main/java/com/sap/cds/sdm/constants/SDMConstants.java @@ -85,6 +85,13 @@ private SDMConstants() { public static final String FAILED_TO_FETCH_UP_ID = "Failed to fetch up_id"; public static final String FAILED_TO_FETCH_FACET = "Invalid facet format, unable to extract required information."; + public static final String PARENT_ENTITY_NOT_FOUND_ERROR = "Unable to find parent entity: %s"; + public static final String COMPOSITION_NOT_FOUND_ERROR = + "Unable to find composition '%s' in entity: %s"; + public static final String TARGET_ATTACHMENT_ENTITY_NOT_FOUND_ERROR = + "Unable to find target attachment entity: %s"; + public static final String INVALID_FACET_FORMAT_ERROR = + "Invalid facet format. Expected: Service.Entity.Composition, got: %s"; public static String nameConstraintMessage( List fileNameWithRestrictedCharacters, String operation) { diff --git a/sdm/src/main/java/com/sap/cds/sdm/model/CopyAttachmentInput.java b/sdm/src/main/java/com/sap/cds/sdm/model/CopyAttachmentInput.java index cdbf732b..43fb326d 100644 --- a/sdm/src/main/java/com/sap/cds/sdm/model/CopyAttachmentInput.java +++ b/sdm/src/main/java/com/sap/cds/sdm/model/CopyAttachmentInput.java @@ -3,10 +3,12 @@ import java.util.List; /** - * The class {@link CopyAttachmentInput} is used to store the input for creating an attachment. + * The class {@link CopyAttachmentInput} is used to store the input for copying attachments. This + * model supports both regular entities and projection entities by using facet-based navigation. * - * @param upId The keys for the attachment entity - * @param facet - * @param objectIds + * @param upId The key of the parent entity instance + * @param facet The full facet path (e.g., "Service.Entity.composition") that will be internally + * parsed to determine parent entity and composition name + * @param objectIds The list of attachment object IDs to be copied */ public record CopyAttachmentInput(String upId, String facet, List objectIds) {} diff --git a/sdm/src/main/java/com/sap/cds/sdm/model/CopyAttachmentsRequest.java b/sdm/src/main/java/com/sap/cds/sdm/model/CopyAttachmentsRequest.java new file mode 100644 index 00000000..e6e3cc5b --- /dev/null +++ b/sdm/src/main/java/com/sap/cds/sdm/model/CopyAttachmentsRequest.java @@ -0,0 +1,110 @@ +package com.sap.cds.sdm.model; + +import com.sap.cds.sdm.service.handler.AttachmentCopyEventContext; +import java.util.List; + +/** + * Parameter object for copyAttachmentsToSDM method to reduce parameter count and improve code + * maintainability. + */ +public class CopyAttachmentsRequest { + private final AttachmentCopyEventContext context; + private final List objectIds; + private final String folderId; + private final String repositoryId; + private final SDMCredentials sdmCredentials; + private final Boolean isSystemUser; + private final boolean folderExists; + + private CopyAttachmentsRequest(Builder builder) { + this.context = builder.context; + this.objectIds = builder.objectIds; + this.folderId = builder.folderId; + this.repositoryId = builder.repositoryId; + this.sdmCredentials = builder.sdmCredentials; + this.isSystemUser = builder.isSystemUser; + this.folderExists = builder.folderExists; + } + + // Getters + public AttachmentCopyEventContext getContext() { + return context; + } + + public List getObjectIds() { + return objectIds; + } + + public String getFolderId() { + return folderId; + } + + public String getRepositoryId() { + return repositoryId; + } + + public SDMCredentials getSdmCredentials() { + return sdmCredentials; + } + + public Boolean getIsSystemUser() { + return isSystemUser; + } + + public boolean isFolderExists() { + return folderExists; + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private AttachmentCopyEventContext context; + private List objectIds; + private String folderId; + private String repositoryId; + private SDMCredentials sdmCredentials; + private Boolean isSystemUser; + private boolean folderExists; + + public Builder context(AttachmentCopyEventContext context) { + this.context = context; + return this; + } + + public Builder objectIds(List objectIds) { + this.objectIds = objectIds; + return this; + } + + public Builder folderId(String folderId) { + this.folderId = folderId; + return this; + } + + public Builder repositoryId(String repositoryId) { + this.repositoryId = repositoryId; + return this; + } + + public Builder sdmCredentials(SDMCredentials sdmCredentials) { + this.sdmCredentials = sdmCredentials; + return this; + } + + public Builder isSystemUser(Boolean isSystemUser) { + this.isSystemUser = isSystemUser; + return this; + } + + public Builder folderExists(boolean folderExists) { + this.folderExists = folderExists; + return this; + } + + public CopyAttachmentsRequest build() { + return new CopyAttachmentsRequest(this); + } + } +} diff --git a/sdm/src/main/java/com/sap/cds/sdm/model/CreateDraftEntriesRequest.java b/sdm/src/main/java/com/sap/cds/sdm/model/CreateDraftEntriesRequest.java new file mode 100644 index 00000000..3f8bded5 --- /dev/null +++ b/sdm/src/main/java/com/sap/cds/sdm/model/CreateDraftEntriesRequest.java @@ -0,0 +1,121 @@ +package com.sap.cds.sdm.model; + +import java.util.List; + +/** + * Parameter object for createDraftEntries method to reduce parameter count and improve code + * maintainability. + */ +public class CreateDraftEntriesRequest { + private final List> attachmentsMetadata; + private final List populatedDocuments; + private final String parentEntity; + private final String compositionName; + private final String upID; + private final String upIdKey; + private final String repositoryId; + private final String folderId; + + private CreateDraftEntriesRequest(Builder builder) { + this.attachmentsMetadata = builder.attachmentsMetadata; + this.populatedDocuments = builder.populatedDocuments; + this.parentEntity = builder.parentEntity; + this.compositionName = builder.compositionName; + this.upID = builder.upID; + this.upIdKey = builder.upIdKey; + this.repositoryId = builder.repositoryId; + this.folderId = builder.folderId; + } + + // Getters + public List> getAttachmentsMetadata() { + return attachmentsMetadata; + } + + public List getPopulatedDocuments() { + return populatedDocuments; + } + + public String getParentEntity() { + return parentEntity; + } + + public String getCompositionName() { + return compositionName; + } + + public String getUpID() { + return upID; + } + + public String getUpIdKey() { + return upIdKey; + } + + public String getRepositoryId() { + return repositoryId; + } + + public String getFolderId() { + return folderId; + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private List> attachmentsMetadata; + private List populatedDocuments; + private String parentEntity; + private String compositionName; + private String upID; + private String upIdKey; + private String repositoryId; + private String folderId; + + public Builder attachmentsMetadata(List> attachmentsMetadata) { + this.attachmentsMetadata = attachmentsMetadata; + return this; + } + + public Builder populatedDocuments(List populatedDocuments) { + this.populatedDocuments = populatedDocuments; + return this; + } + + public Builder parentEntity(String parentEntity) { + this.parentEntity = parentEntity; + return this; + } + + public Builder compositionName(String compositionName) { + this.compositionName = compositionName; + return this; + } + + public Builder upID(String upID) { + this.upID = upID; + return this; + } + + public Builder upIdKey(String upIdKey) { + this.upIdKey = upIdKey; + return this; + } + + public Builder repositoryId(String repositoryId) { + this.repositoryId = repositoryId; + return this; + } + + public Builder folderId(String folderId) { + this.folderId = folderId; + return this; + } + + public CreateDraftEntriesRequest build() { + return new CreateDraftEntriesRequest(this); + } + } +} diff --git a/sdm/src/main/java/com/sap/cds/sdm/persistence/DBQuery.java b/sdm/src/main/java/com/sap/cds/sdm/persistence/DBQuery.java index 64ed19b4..1fa13df5 100644 --- a/sdm/src/main/java/com/sap/cds/sdm/persistence/DBQuery.java +++ b/sdm/src/main/java/com/sap/cds/sdm/persistence/DBQuery.java @@ -7,10 +7,14 @@ import com.sap.cds.ql.Update; import com.sap.cds.ql.cqn.CqnSelect; import com.sap.cds.ql.cqn.CqnUpdate; +import com.sap.cds.reflect.CdsAssociationType; +import com.sap.cds.reflect.CdsElement; import com.sap.cds.reflect.CdsEntity; +import com.sap.cds.reflect.CdsModel; import com.sap.cds.sdm.constants.SDMConstants; import com.sap.cds.sdm.model.CmisDocument; import com.sap.cds.sdm.service.handler.AttachmentCopyEventContext; +import com.sap.cds.services.ServiceException; import com.sap.cds.services.persistence.PersistenceService; import java.util.*; @@ -65,13 +69,46 @@ public CmisDocument getObjectIdForAttachmentID( public CmisDocument getAttachmentForObjectID( PersistenceService persistenceService, String id, AttachmentCopyEventContext context) { - Optional attachmentEntity = context.getModel().findEntity(context.getFacet()); + + // Use the new API to resolve the target attachment entity + String parentEntity = context.getParentEntity(); + String compositionName = context.getCompositionName(); + CdsModel model = context.getModel(); + + // Find the parent entity + Optional optionalParentEntity = model.findEntity(parentEntity); + if (optionalParentEntity.isEmpty()) { + throw new ServiceException( + String.format(SDMConstants.PARENT_ENTITY_NOT_FOUND_ERROR, parentEntity)); + } + + // Find the composition element in the parent entity + Optional compositionElement = + optionalParentEntity.get().findElement(compositionName); + if (compositionElement.isEmpty() || !compositionElement.get().getType().isAssociation()) { + throw new ServiceException( + String.format(SDMConstants.COMPOSITION_NOT_FOUND_ERROR, compositionName, parentEntity)); + } + + // Get the target entity of the composition + CdsAssociationType assocType = (CdsAssociationType) compositionElement.get().getType(); + String targetEntityName = assocType.getTarget().getQualifiedName(); + + // Find the target attachment entity + Optional attachmentEntity = model.findEntity(targetEntityName); + if (attachmentEntity.isEmpty()) { + throw new ServiceException( + String.format(SDMConstants.TARGET_ATTACHMENT_ENTITY_NOT_FOUND_ERROR, targetEntityName)); + } + + // Search in active entity first CqnSelect q = Select.from(attachmentEntity.get()) .columns("linkUrl", "type") .where(doc -> doc.get("objectId").eq(id)); Result result = persistenceService.run(q); Optional res = result.first(); + CmisDocument cmisDocument = new CmisDocument(); if (res.isPresent()) { Row row = res.get(); @@ -79,17 +116,19 @@ public CmisDocument getAttachmentForObjectID( cmisDocument.setUrl(row.get("linkUrl") != null ? row.get("linkUrl").toString() : null); } else { // Check in draft table as well - attachmentEntity = context.getModel().findEntity(context.getFacet() + "_drafts"); - q = - Select.from(attachmentEntity.get()) - .columns("linkUrl", "type") - .where(doc -> doc.get("objectId").eq(id)); - result = persistenceService.run(q); - res = result.first(); - if (res.isPresent()) { - Row row = res.get(); - cmisDocument.setType(row.get("type") != null ? row.get("type").toString() : null); - cmisDocument.setUrl(row.get("linkUrl") != null ? row.get("linkUrl").toString() : null); + Optional attachmentDraftEntity = model.findEntity(targetEntityName + "_drafts"); + if (attachmentDraftEntity.isPresent()) { + q = + Select.from(attachmentDraftEntity.get()) + .columns("linkUrl", "type") + .where(doc -> doc.get("objectId").eq(id)); + result = persistenceService.run(q); + res = result.first(); + if (res.isPresent()) { + Row row = res.get(); + cmisDocument.setType(row.get("type") != null ? row.get("type").toString() : null); + cmisDocument.setUrl(row.get("linkUrl") != null ? row.get("linkUrl").toString() : null); + } } } return cmisDocument; diff --git a/sdm/src/main/java/com/sap/cds/sdm/service/RegisterService.java b/sdm/src/main/java/com/sap/cds/sdm/service/RegisterService.java index 6d26c572..078668b0 100644 --- a/sdm/src/main/java/com/sap/cds/sdm/service/RegisterService.java +++ b/sdm/src/main/java/com/sap/cds/sdm/service/RegisterService.java @@ -7,5 +7,13 @@ public interface RegisterService extends Service { String SDM_NAME = "SDMAttachmentService$Default"; String EVENT_COPY_ATTACHMENT = "COPY_ATTACHMENT"; + /** + * Copies attachments using the facet-based approach. This method supports both regular entities + * and projection entities by internally parsing the facet to determine parent entity and + * composition name. + * + * @param input The copy attachment input containing facet and object IDs + * @param isSystemUser Whether to use system user flow + */ public void copyAttachments(CopyAttachmentInput input, boolean isSystemUser); } diff --git a/sdm/src/main/java/com/sap/cds/sdm/service/SDMAttachmentsService.java b/sdm/src/main/java/com/sap/cds/sdm/service/SDMAttachmentsService.java index 242895df..c99fc4dc 100644 --- a/sdm/src/main/java/com/sap/cds/sdm/service/SDMAttachmentsService.java +++ b/sdm/src/main/java/com/sap/cds/sdm/service/SDMAttachmentsService.java @@ -10,6 +10,7 @@ import com.sap.cds.feature.attachments.service.model.servicehandler.AttachmentReadEventContext; import com.sap.cds.feature.attachments.service.model.servicehandler.AttachmentRestoreEventContext; import com.sap.cds.feature.attachments.service.model.servicehandler.DeletionUserInfo; +import com.sap.cds.sdm.constants.SDMConstants; import com.sap.cds.sdm.model.CopyAttachmentInput; import com.sap.cds.sdm.service.handler.AttachmentCopyEventContext; import com.sap.cds.services.ServiceDelegator; @@ -35,9 +36,21 @@ public void copyAttachments(CopyAttachmentInput input, boolean isSystemUser) { input.facet(), input.objectIds(), isSystemUser); + + // Parse facet to extract parent entity and composition name + String[] facetParts = input.facet().split("\\."); + if (facetParts.length < 3) { + throw new IllegalArgumentException( + String.format(SDMConstants.INVALID_FACET_FORMAT_ERROR, input.facet())); + } + + String parentEntity = facetParts[0] + "." + facetParts[1]; // Service.Entity + String compositionName = facetParts[2]; // composition name + var copyContext = AttachmentCopyEventContext.create(); copyContext.setUpId(input.upId()); - copyContext.setFacet(input.facet()); + copyContext.setParentEntity(parentEntity); + copyContext.setCompositionName(compositionName); copyContext.setObjectIds(input.objectIds()); copyContext.setSystemUser(isSystemUser); diff --git a/sdm/src/main/java/com/sap/cds/sdm/service/handler/AttachmentCopyEventContext.java b/sdm/src/main/java/com/sap/cds/sdm/service/handler/AttachmentCopyEventContext.java index ed29cd67..e02b358b 100644 --- a/sdm/src/main/java/com/sap/cds/sdm/service/handler/AttachmentCopyEventContext.java +++ b/sdm/src/main/java/com/sap/cds/sdm/service/handler/AttachmentCopyEventContext.java @@ -10,8 +10,12 @@ import java.util.List; /** - * The {@link AttachmentCopyEventContext} is used to store the context of the create attachment - * event. + * The {@link AttachmentCopyEventContext} is used to store the context of the copy attachment event. + * This interface provides methods to handle attachment copying for both regular entities and + * projection entities by working with parent entities and their composition relationships. + * + *

For projection entities, the API uses the parent entity that defines the attachments + * composition and the composition name to properly navigate the relationship hierarchy. */ @EventName(RegisterService.EVENT_COPY_ATTACHMENT) public interface AttachmentCopyEventContext extends AttachmentCreateEventContext { @@ -27,48 +31,85 @@ static AttachmentCopyEventContext create() { } /** - * @return The id of the attachment storage entity or {@code null} if no id was specified + * Gets the ID of the parent entity instance for which attachments are being copied. This + * represents the key values of the entity that contains the attachment composition. + * + * @return The id of the parent entity instance or {@code null} if no id was specified */ String getUpId(); /** - * Sets the ID of the content for the attachment storage + * Sets the ID of the parent entity instance for which attachments are being copied. This should + * be the key value of the entity that contains the attachment composition. * - * @param upId The key of the content + * @param upId The key of the parent entity instance */ void setUpId(String upId); - String getFacet(); + /** + * Gets the qualified name of the parent entity that defines the attachments composition. This is + * the entity that contains the composition relationship to the attachment entity. + * + * @return The qualified name of the parent entity or {@code null} if not specified + */ + String getParentEntity(); /** - * Sets the ID of the content for the attachment storage + * Sets the qualified name of the parent entity that defines the attachments composition. This + * entity should contain the composition relationship to the attachment entity. * - * @param facet The key of the content + * @param parentEntity The qualified name of the parent entity (e.g., "Service.Entity") */ - void setFacet(String facet); + void setParentEntity(String parentEntity); /** - * @return The IDs of the attachment storage entity or {@code Collections.emptyMap} if no id was + * Gets the name of the composition property that links the parent entity to the attachment + * entity. This is the property name used in the composition relationship. + * + *

Examples: "attachments", "references" + * + * @return The name of the composition property or {@code null} if not specified + */ + String getCompositionName(); + + /** + * Sets the name of the composition property that links the parent entity to the attachment + * entity. This should match the property name defined in the CDS model. + * + * @param compositionName The name of the composition property (e.g., "references") + */ + void setCompositionName(String compositionName); + + /** + * Gets the list of object IDs representing the attachments to be copied. These are typically the + * IDs of existing attachment records that should be duplicated. + * + * @return The list of attachment object IDs or {@code Collections.emptyList()} if no IDs were * specified */ List getObjectIds(); /** - * Sets the id af the attachment entity for the attachment storage + * Sets the list of object IDs representing the attachments to be copied. Each ID should + * correspond to an existing attachment record. * - * @param ids The key of the attachment entity which defines the content field + * @param ids The list of attachment object IDs to be copied */ void setObjectIds(List ids); /** - * @return {@code true} if the user flow is used, {@code false} otherwise + * Gets whether the system user flow is being used for the copy operation. System user flow + * typically bypasses certain authorization checks and user-specific logic. + * + * @return {@code true} if the system user flow is used, {@code false} for regular user flow */ Boolean getSystemUser(); /** - * Sets whether the system user flow is used. + * Sets whether the system user flow should be used for the copy operation. Use system user flow + * when the operation should bypass user-specific authorization or logic. * - * @param systemUser {@code true} if the system user flow is used, {@code false} otherwise + * @param systemUser {@code true} to use system user flow, {@code false} for regular user flow */ void setSystemUser(boolean systemUser); } diff --git a/sdm/src/main/java/com/sap/cds/sdm/service/handler/SDMCustomServiceHandler.java b/sdm/src/main/java/com/sap/cds/sdm/service/handler/SDMCustomServiceHandler.java index b87e0bb3..63a34e5c 100644 --- a/sdm/src/main/java/com/sap/cds/sdm/service/handler/SDMCustomServiceHandler.java +++ b/sdm/src/main/java/com/sap/cds/sdm/service/handler/SDMCustomServiceHandler.java @@ -8,6 +8,8 @@ import com.sap.cds.sdm.constants.SDMConstants; import com.sap.cds.sdm.handler.TokenHandler; import com.sap.cds.sdm.model.CmisDocument; +import com.sap.cds.sdm.model.CopyAttachmentsRequest; +import com.sap.cds.sdm.model.CreateDraftEntriesRequest; import com.sap.cds.sdm.model.SDMCredentials; import com.sap.cds.sdm.persistence.DBQuery; import com.sap.cds.sdm.service.RegisterService; @@ -33,6 +35,26 @@ public class SDMCustomServiceHandler { private final TokenHandler tokenHandler; private final PersistenceService persistenceService; + // Result class for copyAttachmentsToSDM method + private static class CopyAttachmentsResult { + private final List> attachmentsMetadata; + private final List populatedDocuments; + + public CopyAttachmentsResult( + List> attachmentsMetadata, List populatedDocuments) { + this.attachmentsMetadata = attachmentsMetadata; + this.populatedDocuments = populatedDocuments; + } + + public List> getAttachmentsMetadata() { + return attachmentsMetadata; + } + + public List getPopulatedDocuments() { + return populatedDocuments; + } + } + public SDMCustomServiceHandler( SDMService sdmService, List draftService, @@ -48,22 +70,64 @@ public SDMCustomServiceHandler( @On(event = RegisterService.EVENT_COPY_ATTACHMENT) public void copyAttachments(AttachmentCopyEventContext context) throws IOException { - String[] splitFacet = context.getFacet().split("\\."); - if (splitFacet.length < 3) { - throw new ServiceException(SDMConstants.FAILED_TO_FETCH_FACET); - } - String facet = splitFacet[2]; + + String parentEntity = context.getParentEntity(); + String compositionName = context.getCompositionName(); String upID = context.getUpId(); - String folderName = upID + "__" + facet; + String folderName = upID + "__" + compositionName; String repositoryId = SDMConstants.REPOSITORY_ID; Boolean isSystemUser = context.getSystemUser(); - boolean folderExists = true; SDMCredentials sdmCredentials = tokenHandler.getSDMCredentials(); + // Check if folder exists before trying to create it + boolean folderExists = + sdmService.getFolderIdByPath(folderName, repositoryId, sdmCredentials, isSystemUser) + != null; + String folderId = ensureFolderExists(folderName, repositoryId, sdmCredentials, isSystemUser); + + List objectIds = context.getObjectIds(); + + CopyAttachmentsRequest request = + CopyAttachmentsRequest.builder() + .context(context) + .objectIds(objectIds) + .folderId(folderId) + .repositoryId(repositoryId) + .sdmCredentials(sdmCredentials) + .isSystemUser(isSystemUser) + .folderExists(folderExists) + .build(); + + CopyAttachmentsResult copyResult = copyAttachmentsToSDM(request); + + List> attachmentsMetadata = copyResult.getAttachmentsMetadata(); + List populatedDocuments = copyResult.getPopulatedDocuments(); + + String upIdKey = resolveUpIdKey(context, parentEntity, compositionName); + + CreateDraftEntriesRequest draftRequest = + CreateDraftEntriesRequest.builder() + .attachmentsMetadata(attachmentsMetadata) + .populatedDocuments(populatedDocuments) + .parentEntity(parentEntity) + .compositionName(compositionName) + .upID(upID) + .upIdKey(upIdKey) + .repositoryId(repositoryId) + .folderId(folderId) + .build(); + + createDraftEntries(draftRequest); + + context.setCompleted(); + } + + private String ensureFolderExists( + String folderName, String repositoryId, SDMCredentials sdmCredentials, Boolean isSystemUser) + throws IOException { String folderId = sdmService.getFolderIdByPath(folderName, repositoryId, sdmCredentials, isSystemUser); if (folderId == null) { - folderExists = false; folderId = sdmService.createFolder( folderName, SDMConstants.REPOSITORY_ID, sdmCredentials, isSystemUser); @@ -71,47 +135,101 @@ public void copyAttachments(AttachmentCopyEventContext context) throws IOExcepti JSONObject succinctProperties = jsonObject.getJSONObject("succinctProperties"); folderId = succinctProperties.getString("cmis:objectId"); } - CmisDocument cmisDocument = new CmisDocument(); + return folderId; + } - List objectIds = context.getObjectIds(); + private CopyAttachmentsResult copyAttachmentsToSDM(CopyAttachmentsRequest request) + throws IOException { List> attachmentsMetadata = new ArrayList<>(); - for (String objectId : objectIds) { - // get Link Url from objectId and set to cmisDocument - cmisDocument = dbQuery.getAttachmentForObjectID(persistenceService, objectId, context); + List populatedDocuments = new ArrayList<>(); + + for (String objectId : request.getObjectIds()) { + CmisDocument cmisDocument = + dbQuery.getAttachmentForObjectID(persistenceService, objectId, request.getContext()); cmisDocument.setObjectId(objectId); - cmisDocument.setRepositoryId(repositoryId); - cmisDocument.setFolderId(folderId); + cmisDocument.setRepositoryId(request.getRepositoryId()); + cmisDocument.setFolderId(request.getFolderId()); + + // Create individual document for each attachment with its own type and linkUrl + CmisDocument populatedDocument = new CmisDocument(); + populatedDocument.setType(cmisDocument.getType()); + populatedDocument.setUrl(cmisDocument.getUrl()); + populatedDocuments.add(populatedDocument); + try { attachmentsMetadata.add( - sdmService.copyAttachment(cmisDocument, sdmCredentials, isSystemUser)); + sdmService.copyAttachment( + cmisDocument, request.getSdmCredentials(), request.getIsSystemUser())); } catch (ServiceException e) { - if (!folderExists) { - // deleteFolder - sdmService.deleteDocument("deleteTree", folderId, context.getUserInfo().getName()); - throw new ServiceException(e.getMessage()); - } else { - for (List attachmentMetadata : attachmentsMetadata) { - // delete the copied attachments - sdmService.deleteDocument( - "delete", attachmentMetadata.get(2), context.getUserInfo().getName()); - } - throw new ServiceException(e.getMessage()); - } + handleCopyFailure( + request.getContext(), + request.getFolderId(), + request.isFolderExists(), + attachmentsMetadata, + e); + } + } + + return new CopyAttachmentsResult(attachmentsMetadata, populatedDocuments); + } + + private void handleCopyFailure( + AttachmentCopyEventContext context, + String folderId, + boolean folderExists, + List> attachmentsMetadata, + ServiceException e) + throws IOException { + if (!folderExists) { + sdmService.deleteDocument("deleteTree", folderId, context.getUserInfo().getName()); + } else { + for (List attachmentMetadata : attachmentsMetadata) { + sdmService.deleteDocument( + "delete", attachmentMetadata.get(2), context.getUserInfo().getName()); } } + throw new ServiceException(e.getMessage()); + } - String upIdKey = ""; + private String resolveUpIdKey( + AttachmentCopyEventContext context, String parentEntity, String compositionName) { CdsModel model = context.getModel(); - Optional attachmentDraftEntity = model.findEntity(context.getFacet() + "_drafts"); - Optional upAssociation = attachmentDraftEntity.get().findAssociation("up_"); - if (upAssociation.isPresent()) { - CdsElement association = upAssociation.get(); - CdsAssociationType assocType = association.getType(); - List fkElements = assocType.refs().map(ref -> "up__" + ref.path()).toList(); - upIdKey = fkElements.get(0); + Optional optionalParentEntity = model.findEntity(parentEntity); + if (optionalParentEntity.isEmpty()) { + throw new ServiceException("Unable to find parent entity: " + parentEntity); + } + + Optional compositionElement = + optionalParentEntity.get().findElement(compositionName); + if (compositionElement.isEmpty() || !compositionElement.get().getType().isAssociation()) { + throw new ServiceException( + "Unable to find composition '" + compositionName + "' in entity: " + parentEntity); + } + + CdsAssociationType assocType = (CdsAssociationType) compositionElement.get().getType(); + String targetEntityName = assocType.getTarget().getQualifiedName(); + + Optional attachmentDraftEntity = model.findEntity(targetEntityName + "_drafts"); + if (attachmentDraftEntity.isPresent()) { + Optional upAssociation = attachmentDraftEntity.get().findAssociation("up_"); + if (upAssociation.isPresent()) { + CdsElement association = upAssociation.get(); + CdsAssociationType upAssocType = association.getType(); + List fkElements = upAssocType.refs().map(ref -> "up__" + ref.path()).toList(); + String upIdKey = fkElements.get(0); + return upIdKey; + } } - Map updatedFields = new HashMap<>(); - for (List attachmentMetadata : attachmentsMetadata) { + return null; + } + + private void createDraftEntries(CreateDraftEntriesRequest request) { + + for (int i = 0; i < request.getAttachmentsMetadata().size(); i++) { + List attachmentMetadata = request.getAttachmentsMetadata().get(i); + CmisDocument cmisDocument = request.getPopulatedDocuments().get(i); + Map updatedFields = new HashMap<>(); + String fileName = attachmentMetadata.get(0); String mimeType = attachmentMetadata.get(1); if (mimeType.equalsIgnoreCase("application/internet-shortcut")) { @@ -119,28 +237,52 @@ public void copyAttachments(AttachmentCopyEventContext context) throws IOExcepti fileName = fileName.substring(0, dotIndex); } String newObjectId = attachmentMetadata.get(2); + updatedFields.put("objectId", newObjectId); - updatedFields.put("repositoryId", repositoryId); - updatedFields.put("folderId", folderId); + updatedFields.put("repositoryId", request.getRepositoryId()); + updatedFields.put("folderId", request.getFolderId()); updatedFields.put("status", "Clean"); updatedFields.put("mimeType", mimeType); - updatedFields.put("type", cmisDocument.getType()); + updatedFields.put("type", cmisDocument.getType()); // Individual type for each attachment updatedFields.put("fileName", fileName); updatedFields.put("HasDraftEntity", false); updatedFields.put("HasActiveEntity", false); - updatedFields.put("linkUrl", cmisDocument.getUrl()); + updatedFields.put("linkUrl", cmisDocument.getUrl()); // Individual linkUrl for each attachment updatedFields.put( - "contentId", newObjectId + ":" + folderId + ":" + context.getFacet() + ":" + mimeType); - updatedFields.put(upIdKey, upID); - - var insert = Insert.into(context.getFacet()).entry(updatedFields); - for (DraftService draftS : draftService) { - // Check if the draft service name matches the context facet - if (context.getFacet().contains(draftS.getName())) { - draftS.newDraft(insert); - } + "contentId", + newObjectId + + ":" + + request.getFolderId() + + ":" + + request.getParentEntity() + + "." + + request.getCompositionName() + + ":" + + mimeType); + updatedFields.put(request.getUpIdKey(), request.getUpID()); + + String baseKeyField = + request.getUpIdKey() != null ? request.getUpIdKey().replace("up__", "") : "ID"; + var insert = + Insert.into( + request.getParentEntity(), + e -> + e.filter(e.get(baseKeyField).eq(request.getUpID())) + .to(request.getCompositionName())) + .entry(updatedFields); + + DraftService matchingService = + draftService.stream() + .filter(ds -> request.getParentEntity().contains(ds.getName())) + .findFirst() + .orElse(null); + + if (matchingService != null) { + matchingService.newDraft(insert); + } else { + throw new ServiceException( + "No suitable service found for entity: " + request.getParentEntity()); } } - context.setCompleted(); } } diff --git a/sdm/src/main/java/com/sap/cds/sdm/service/handler/SDMServiceGenericHandler.java b/sdm/src/main/java/com/sap/cds/sdm/service/handler/SDMServiceGenericHandler.java index cdd7ca2c..b959d24e 100644 --- a/sdm/src/main/java/com/sap/cds/sdm/service/handler/SDMServiceGenericHandler.java +++ b/sdm/src/main/java/com/sap/cds/sdm/service/handler/SDMServiceGenericHandler.java @@ -69,8 +69,12 @@ public void copyAttachments(EventContext context) throws IOException { String upID = context.get("up__ID").toString(); String objectIdsString = context.get("objectIds").toString(); List objectIds = Arrays.stream(objectIdsString.split(",")).map(String::trim).toList(); - var copyEventInput = - new CopyAttachmentInput(upID, context.getTarget().getQualifiedName(), objectIds); + + // Use the full target qualified name as the facet + String facet = context.getTarget().getQualifiedName(); + + var copyEventInput = new CopyAttachmentInput(upID, facet, objectIds); + attachmentService.copyAttachments(copyEventInput, context.getUserInfo().isSystemUser()); context.setCompleted(); } diff --git a/sdm/src/test/java/unit/com/sap/cds/sdm/service/CopyAttachmentInputTest.java b/sdm/src/test/java/unit/com/sap/cds/sdm/service/CopyAttachmentInputTest.java new file mode 100644 index 00000000..57cd25a6 --- /dev/null +++ b/sdm/src/test/java/unit/com/sap/cds/sdm/service/CopyAttachmentInputTest.java @@ -0,0 +1,65 @@ +package unit.com.sap.cds.sdm.service; + +import static org.junit.jupiter.api.Assertions.*; + +import com.sap.cds.sdm.model.CopyAttachmentInput; +import java.util.List; +import org.junit.jupiter.api.Test; + +public class CopyAttachmentInputTest { + + @Test + void testFacetBasedInput() { + // Test the new 3-parameter approach + String upId = "123"; + String facet = "SourcingEventService.TermDefinitionsServiceEntity.references"; + List objectIds = List.of("obj1", "obj2", "obj3"); + + CopyAttachmentInput input = new CopyAttachmentInput(upId, facet, objectIds); + + assertEquals("123", input.upId()); + assertEquals("SourcingEventService.TermDefinitionsServiceEntity.references", input.facet()); + assertEquals(List.of("obj1", "obj2", "obj3"), input.objectIds()); + } + + @Test + void testFacetParsing() { + // This test shows how the facet should be parsed + String facet = "SourcingEventService.TermDefinitionsServiceEntity.references"; + String[] parts = facet.split("\\."); + + assertEquals(3, parts.length); + assertEquals("SourcingEventService", parts[0]); + assertEquals("TermDefinitionsServiceEntity", parts[1]); + assertEquals("references", parts[2]); + + String parentEntity = parts[0] + "." + parts[1]; + String compositionName = parts[2]; + + assertEquals("SourcingEventService.TermDefinitionsServiceEntity", parentEntity); + assertEquals("references", compositionName); + } + + @Test + void testProjectionEntityFacet() { + // Test with a projection entity facet - should parse the same way + String projectionFacet = "MyService.MyProjectionEntity.attachments"; + String[] parts = projectionFacet.split("\\."); + + assertEquals(3, parts.length); + String parentEntity = parts[0] + "." + parts[1]; + String compositionName = parts[2]; + + assertEquals("MyService.MyProjectionEntity", parentEntity); + assertEquals("attachments", compositionName); + } + + @Test + void testInvalidFacetFormat() { + // Test error handling for invalid facet formats + String invalidFacet = "Service.Entity"; // Missing composition + String[] parts = invalidFacet.split("\\."); + + assertTrue(parts.length < 3, "Should detect invalid facet format"); + } +} diff --git a/sdm/src/test/java/unit/com/sap/cds/sdm/service/handler/SDMCustomServiceHandlerTest.java b/sdm/src/test/java/unit/com/sap/cds/sdm/service/handler/SDMCustomServiceHandlerTest.java index 851c412f..9b2d1176 100644 --- a/sdm/src/test/java/unit/com/sap/cds/sdm/service/handler/SDMCustomServiceHandlerTest.java +++ b/sdm/src/test/java/unit/com/sap/cds/sdm/service/handler/SDMCustomServiceHandlerTest.java @@ -126,21 +126,6 @@ void testCopyAttachments_HappyPathNonLink() throws IOException { verify(context, times(1)).setCompleted(); } - @Test - void testCopyAttachments_InvalidFacetFormat_ThrowsException() throws IOException { - AttachmentCopyEventContext context = mock(AttachmentCopyEventContext.class); - when(context.getFacet()).thenReturn("invalid.facet"); // Only 2 parts - // Other mocks not needed as exception is thrown before they're used - - ServiceException ex = - assertThrows( - ServiceException.class, - () -> { - sdmCustomServiceHandler.copyAttachments(context); - }); - assertTrue(ex.getMessage().contains("Invalid facet format")); - } - @Test void testCopyAttachments_FolderDoesNotExist() throws IOException { // Mock SDMCredentials @@ -203,14 +188,25 @@ void testCopyAttachments_AttachmentCopyFails() throws IOException { // Mock context AttachmentCopyEventContext context = createMockContext(); - context.setObjectIds(List.of(OBJECT_ID, "mockObjectId2")); + // Override the getObjectIds mock to return multiple objects for this test + when(context.getObjectIds()).thenReturn(List.of(OBJECT_ID, "mockObjectId2")); + + // Mock UserInfo for cleanup operations + UserInfo userInfo = mock(UserInfo.class); + when(context.getUserInfo()).thenReturn(userInfo); + when(userInfo.getName()).thenReturn("testUser"); // Act & Assert - try { - sdmCustomServiceHandler.copyAttachments(context); - } catch (ServiceException e) { - verify(sdmService, times(1)).deleteDocument(any(String.class), any(String.class), any()); - } + ServiceException exception = + assertThrows( + ServiceException.class, + () -> { + sdmCustomServiceHandler.copyAttachments(context); + }); + + // Verify that deleteDocument was called for cleanup of the first successful attachment + verify(sdmService, times(1)).deleteDocument(eq("delete"), eq(OBJECT_ID), eq("testUser")); + assertTrue(exception.getMessage().contains("Copy failed")); } @Test @@ -291,19 +287,36 @@ private AttachmentCopyEventContext createMockContext() { CdsAssociationType mockAssociationType = mock(CdsAssociationType.class); CqnElementRef mockCqnElementRef = mock(CqnElementRef.class); - when(context.getFacet()).thenReturn("prefix.someIdentifier." + FACET); + when(context.getParentEntity()).thenReturn("prefix.someIdentifier." + FACET); + when(context.getCompositionName()).thenReturn(FACET); when(context.getUpId()).thenReturn(UP_ID); when(context.getSystemUser()).thenReturn(true); when(context.getObjectIds()).thenReturn(List.of(OBJECT_ID)); // Mock CdsModel and relevant entities and associations CdsModel model = mock(CdsModel.class); - CdsEntity entity = mock(CdsEntity.class); + CdsEntity parentEntity = mock(CdsEntity.class); + CdsEntity draftEntity = mock(CdsEntity.class); + CdsEntity targetEntity = mock(CdsEntity.class); + + // Mock composition element and its type + CdsElement compositionElement = mock(CdsElement.class); + CdsAssociationType compositionType = mock(CdsAssociationType.class); - // Setup expected behavior + // Setup expected behavior for model and parent entity when(context.getModel()).thenReturn(model); - when(model.findEntity(any(String.class))).thenReturn(Optional.of(entity)); - when(entity.findAssociation("up_")).thenReturn(Optional.of(mockAssociationElement)); + when(model.findEntity("prefix.someIdentifier." + FACET)).thenReturn(Optional.of(parentEntity)); + when(model.findEntity(endsWith("_drafts"))).thenReturn(Optional.of(draftEntity)); + + // Mock the composition element in parent entity + when(parentEntity.findElement(FACET)).thenReturn(Optional.of(compositionElement)); + when(compositionElement.getType()).thenReturn(compositionType); + when(compositionType.isAssociation()).thenReturn(true); + when(compositionType.getTarget()).thenReturn(targetEntity); + when(targetEntity.getQualifiedName()).thenReturn("target.entity.name"); + + // Mock the draft entity's up_ association + when(draftEntity.findAssociation("up_")).thenReturn(Optional.of(mockAssociationElement)); when(mockAssociationElement.getType()).thenReturn(mockAssociationType); when(mockAssociationType.refs()).thenReturn(Stream.of(mockCqnElementRef)); when(mockCqnElementRef.path()).thenReturn("ID"); diff --git a/sdm/src/test/java/unit/com/sap/cds/sdm/service/handler/SDMServiceGenericHandlerTest.java b/sdm/src/test/java/unit/com/sap/cds/sdm/service/handler/SDMServiceGenericHandlerTest.java index 177e3b6c..0a3f9800 100644 --- a/sdm/src/test/java/unit/com/sap/cds/sdm/service/handler/SDMServiceGenericHandlerTest.java +++ b/sdm/src/test/java/unit/com/sap/cds/sdm/service/handler/SDMServiceGenericHandlerTest.java @@ -153,6 +153,7 @@ void testCreate_shouldCreateLink() throws IOException { when(draftEntity.getQualifiedName()).thenReturn("MyService.MyEntity.attachments"); when(cdsModel.findEntity("MyService.MyEntity.attachments_drafts")) .thenReturn(Optional.of(draftEntity)); + when(cdsModel.findEntity("MyService.MyEntity.attachments")).thenReturn(Optional.of(cdsEntity)); when(mockContext.getEvent()).thenReturn("createLink"); CqnSelect cqnSelect = mock(CqnSelect.class); @@ -244,6 +245,7 @@ void testCreate_ShouldThrowSpecifiedExceptionWhenMaxCountReached() throws IOExce when(draftEntity.getQualifiedName()).thenReturn("MyService.MyEntity.attachments"); when(cdsModel.findEntity("MyService.MyEntity.attachments_drafts")) .thenReturn(Optional.of(draftEntity)); + when(cdsModel.findEntity("MyService.MyEntity.attachments")).thenReturn(Optional.of(cdsEntity)); when(mockContext.getEvent()).thenReturn("createLink"); CqnSelect cqnSelect = mock(CqnSelect.class); @@ -260,7 +262,8 @@ void testCreate_ShouldThrowSpecifiedExceptionWhenMaxCountReached() throws IOExce when(analyzer.analyze(any(CqnSelect.class))).thenReturn(analysisResult); when(analysisResult.rootKeys()).thenReturn(Map.of("ID", "123")); - // when(cdsModel.findEntity("MyService.MyEntity_drafts")).thenReturn(Optional.of(draftEntity)); + // + when(cdsModel.findEntity("MyService.MyEntity_drafts")).thenReturn(Optional.of(draftEntity)); when(draftEntity.findAssociation("up_")).thenReturn(Optional.of(mockAssociationElement)); when(mockAssociationElement.getType()).thenReturn(mockAssociationType); when(mockAssociationType.refs()).thenReturn(Stream.of(mockCqnElementRef)); @@ -300,6 +303,7 @@ void testCreate_ShouldThrowDefaultExceptionWhenMaxCountReached() throws IOExcept when(draftEntity.getQualifiedName()).thenReturn("MyService.MyEntity.attachments"); when(cdsModel.findEntity("MyService.MyEntity.attachments_drafts")) .thenReturn(Optional.of(draftEntity)); + when(cdsModel.findEntity("MyService.MyEntity.attachments")).thenReturn(Optional.of(cdsEntity)); when(mockContext.getEvent()).thenReturn("createLink"); CqnSelect cqnSelect = mock(CqnSelect.class); @@ -316,7 +320,8 @@ void testCreate_ShouldThrowDefaultExceptionWhenMaxCountReached() throws IOExcept when(analyzer.analyze(any(CqnSelect.class))).thenReturn(analysisResult); when(analysisResult.rootKeys()).thenReturn(Map.of("ID", "123")); - // when(cdsModel.findEntity("MyService.MyEntity_drafts")).thenReturn(Optional.of(draftEntity)); + // + when(cdsModel.findEntity("MyService.MyEntity_drafts")).thenReturn(Optional.of(draftEntity)); when(draftEntity.findAssociation("up_")).thenReturn(Optional.of(mockAssociationElement)); when(mockAssociationElement.getType()).thenReturn(mockAssociationType); when(mockAssociationType.refs()).thenReturn(Stream.of(mockCqnElementRef)); @@ -356,6 +361,7 @@ void testCreate_ShouldThrowExceptionWhenRestrictedCharacterInLinkName() throws I when(draftEntity.getQualifiedName()).thenReturn("MyService.MyEntity.attachments"); when(cdsModel.findEntity("MyService.MyEntity.attachments_drafts")) .thenReturn(Optional.of(draftEntity)); + when(cdsModel.findEntity("MyService.MyEntity.attachments")).thenReturn(Optional.of(cdsEntity)); when(mockContext.getEvent()).thenReturn("createLink"); CqnSelect cqnSelect = mock(CqnSelect.class); @@ -372,7 +378,8 @@ void testCreate_ShouldThrowExceptionWhenRestrictedCharacterInLinkName() throws I when(analyzer.analyze(any(CqnSelect.class))).thenReturn(analysisResult); when(analysisResult.rootKeys()).thenReturn(Map.of("ID", "123")); - // when(cdsModel.findEntity("MyService.MyEntity_drafts")).thenReturn(Optional.of(draftEntity)); + // + when(cdsModel.findEntity("MyService.MyEntity_drafts")).thenReturn(Optional.of(draftEntity)); when(draftEntity.findAssociation("up_")).thenReturn(Optional.of(mockAssociationElement)); when(mockAssociationElement.getType()).thenReturn(mockAssociationType); when(mockAssociationType.refs()).thenReturn(Stream.of(mockCqnElementRef)); @@ -414,6 +421,7 @@ void testCreate_ThrowsServiceExceptionOnDuplicateFile() throws IOException { when(draftEntity.getQualifiedName()).thenReturn("MyService.MyEntity.attachments"); when(cdsModel.findEntity("MyService.MyEntity.attachments_drafts")) .thenReturn(Optional.of(draftEntity)); + when(cdsModel.findEntity("MyService.MyEntity.attachments")).thenReturn(Optional.of(cdsEntity)); when(mockContext.getEvent()).thenReturn("createLink"); CqnSelect cqnSelect = mock(CqnSelect.class); @@ -474,6 +482,7 @@ void testCreate_ThrowsServiceException_WhenCreateDocumentThrowsException() throw when(draftEntity.getQualifiedName()).thenReturn("MyService.MyEntity.attachments"); when(cdsModel.findEntity("MyService.MyEntity.attachments_drafts")) .thenReturn(Optional.of(draftEntity)); + when(cdsModel.findEntity("MyService.MyEntity.attachments")).thenReturn(Optional.of(cdsEntity)); when(mockContext.getEvent()).thenReturn("createLink"); CqnSelect cqnSelect = mock(CqnSelect.class); @@ -541,6 +550,7 @@ void testCreate_ThrowsServiceExceptionOnDuplicateStatus() throws IOException { when(draftEntity.getQualifiedName()).thenReturn("MyService.MyEntity.attachments"); when(cdsModel.findEntity("MyService.MyEntity.attachments_drafts")) .thenReturn(Optional.of(draftEntity)); + when(cdsModel.findEntity("MyService.MyEntity.attachments")).thenReturn(Optional.of(cdsEntity)); when(mockContext.getEvent()).thenReturn("createLink"); CqnSelect cqnSelect = mock(CqnSelect.class); @@ -609,6 +619,7 @@ void testCreate_ThrowsServiceExceptionOnFailStatus() throws IOException { when(draftEntity.getQualifiedName()).thenReturn("MyService.MyEntity.attachments"); when(cdsModel.findEntity("MyService.MyEntity.attachments_drafts")) .thenReturn(Optional.of(draftEntity)); + when(cdsModel.findEntity("MyService.MyEntity.attachments")).thenReturn(Optional.of(cdsEntity)); when(mockContext.getEvent()).thenReturn("createLink"); CqnSelect cqnSelect = mock(CqnSelect.class); @@ -676,6 +687,7 @@ void testCreate_ThrowsServiceExceptionOnUnauthorizedStatus() throws IOException when(draftEntity.getQualifiedName()).thenReturn("MyService.MyEntity.attachments"); when(cdsModel.findEntity("MyService.MyEntity.attachments_drafts")) .thenReturn(Optional.of(draftEntity)); + when(cdsModel.findEntity("MyService.MyEntity.attachments")).thenReturn(Optional.of(cdsEntity)); when(mockContext.getEvent()).thenReturn("createLink"); CqnSelect cqnSelect = mock(CqnSelect.class);