11package com .sap .cds .sdm .service .handler ;
22
3+ import com .sap .cds .Result ;
34import com .sap .cds .ql .Insert ;
45import com .sap .cds .reflect .CdsAssociationType ;
56import com .sap .cds .reflect .CdsElement ;
2425import com .sap .cds .sdm .persistence .DBQuery ;
2526import com .sap .cds .sdm .service .RegisterService ;
2627import com .sap .cds .sdm .service .SDMService ;
28+ import com .sap .cds .sdm .utilities .SDMUtils ;
2729import com .sap .cds .services .EventContext ;
2830import com .sap .cds .services .ServiceException ;
2931import com .sap .cds .services .draft .DraftService ;
@@ -183,6 +185,16 @@ public void moveAttachments(AttachmentMoveEventContext context) throws IOExcepti
183185
184186 SDMCredentials sdmCredentials = tokenHandler .getSDMCredentials ();
185187
188+ // Check maxCount constraint before attempting move
189+ List <Map <String , String >> failedAttachments =
190+ checkMaxCountConstraintForMove (context , parentEntity , compositionName , upID , objectIds );
191+ if (!failedAttachments .isEmpty ()) {
192+ // All attachments failed maxCount validation
193+ context .setFailedAttachments (failedAttachments );
194+ context .setCompleted ();
195+ return ;
196+ }
197+
186198 // Ensure target folder exists in SDM before attempting moves
187199 TargetFolderInfo folderInfo =
188200 ensureTargetFolderReady (
@@ -206,17 +218,17 @@ public void moveAttachments(AttachmentMoveEventContext context) throws IOExcepti
206218
207219 List <List <String >> movedAttachmentsMetadata = moveResult .getMovedAttachmentsMetadata ();
208220 List <CmisDocument > populatedDocuments = moveResult .getPopulatedDocuments ();
209- List <Map <String , String >> failedAttachments = moveResult .getFailedAttachments ();
221+ List <Map <String , String >> moveFailures = moveResult .getFailedAttachments ();
210222 List <String > successfulObjectIds = moveResult .getSuccessfulObjectIds ();
211223
212224 // Return failed attachments to caller
213- context .setFailedAttachments (new ArrayList <>(failedAttachments ));
225+ context .setFailedAttachments (new ArrayList <>(moveFailures ));
214226
215227 // Show warning if there are failures
216- if (!failedAttachments .isEmpty ()) {
228+ if (!moveFailures .isEmpty ()) {
217229 StringBuilder warningMessage =
218230 new StringBuilder ("Failed to move the following attachments:\n " );
219- for (Map <String , String > failure : failedAttachments ) {
231+ for (Map <String , String > failure : moveFailures ) {
220232 warningMessage
221233 .append (" - ObjectId: " )
222234 .append (failure .get (OBJECT_ID_KEY ))
@@ -261,6 +273,126 @@ public void moveAttachments(AttachmentMoveEventContext context) throws IOExcepti
261273 context .setCompleted ();
262274 }
263275
276+ /**
277+ * Checks if moving attachments would exceed the maxCount constraint on the target entity.
278+ *
279+ * @param context the move event context
280+ * @param parentEntity the parent entity name
281+ * @param compositionName the composition name
282+ * @param upID the up ID
283+ * @param objectIds list of attachment object IDs to move
284+ * @return list of failed attachments if constraint is violated, empty list otherwise
285+ */
286+ private List <Map <String , String >> checkMaxCountConstraintForMove (
287+ AttachmentMoveEventContext context ,
288+ String parentEntity ,
289+ String compositionName ,
290+ String upID ,
291+ List <String > objectIds ) {
292+ List <Map <String , String >> failedAttachments = new ArrayList <>();
293+
294+ try {
295+ // Get target attachment entity
296+ Optional <CdsEntity > targetEntityOptional =
297+ context .getModel ().findEntity (parentEntity + "." + compositionName );
298+ if (targetEntityOptional .isEmpty ()) {
299+ logger .warn (
300+ "Target entity {}.{} not found, skipping maxCount validation" ,
301+ parentEntity ,
302+ compositionName );
303+ return failedAttachments ;
304+ }
305+
306+ CdsEntity targetAttachmentEntity = targetEntityOptional .get ();
307+
308+ // Get maxCount and error message from annotations
309+ String errorMessageCount =
310+ SDMUtils .getAttachmentCountAndMessage (
311+ context .getModel ().entities ().toList (), targetAttachmentEntity );
312+ String [] maxCountArr = errorMessageCount .split ("__" );
313+ long maxCount = Long .parseLong (maxCountArr [0 ]);
314+
315+ // If maxCount is 0 or negative, no limit is enforced
316+ if (maxCount <= 0 ) {
317+ return failedAttachments ;
318+ }
319+
320+ // Get target attachment draft entity for querying existing attachments
321+ Optional <CdsEntity > draftEntityOptional =
322+ context .getModel ().findEntity (targetAttachmentEntity .getQualifiedName () + "_drafts" );
323+ if (draftEntityOptional .isEmpty ()) {
324+ logger .warn (
325+ "Draft entity for {} not found, skipping maxCount validation" ,
326+ targetAttachmentEntity .getQualifiedName ());
327+ return failedAttachments ;
328+ }
329+
330+ CdsEntity attachmentDraftEntity = draftEntityOptional .get ();
331+ String upIdKey = SDMUtils .getUpIdKey (attachmentDraftEntity );
332+
333+ // Count existing attachments in target entity
334+ Result result =
335+ dbQuery .getAttachmentsForUPIDAndRepository (
336+ attachmentDraftEntity , persistenceService , upID , upIdKey );
337+ long existingCount = result .rowCount ();
338+ long totalCountAfterMove = existingCount + objectIds .size ();
339+
340+ logger .info (
341+ "MaxCount validation - Target entity: {}, MaxCount: {}, Existing: {}, Moving: {},"
342+ + " Total after move: {}" ,
343+ targetAttachmentEntity .getQualifiedName (),
344+ maxCount ,
345+ existingCount ,
346+ objectIds .size (),
347+ totalCountAfterMove );
348+
349+ // Check if total would exceed maxCount
350+ if (totalCountAfterMove > maxCount ) {
351+ String errorMessage = maxCountArr [1 ];
352+ String failureReason ;
353+
354+ if (errorMessage != null && !"null" .equalsIgnoreCase (errorMessage )) {
355+ // Use custom error message from annotation
356+ failureReason = errorMessage ;
357+ } else {
358+ // Use default error message
359+ failureReason =
360+ String .format (
361+ "Cannot move %d attachment(s). Target entity allows maximum %d attachments, and"
362+ + " already has %d. Maximum count would be exceeded." ,
363+ objectIds .size (), maxCount , existingCount );
364+ }
365+
366+ logger .warn (
367+ "Move operation rejected: Total count {} exceeds maxCount {}. Marking all {} attachments"
368+ + " as failed." ,
369+ totalCountAfterMove ,
370+ maxCount ,
371+ objectIds .size ());
372+
373+ // Mark all attachments as failed
374+ for (String objectId : objectIds ) {
375+ Map <String , String > failure = new HashMap <>();
376+ failure .put (OBJECT_ID_KEY , objectId );
377+ failure .put (FAILURE_REASON_KEY , failureReason );
378+ failedAttachments .add (failure );
379+ }
380+
381+ // Show warning message
382+ context .getMessages ().warn (failureReason );
383+ }
384+ } catch (Exception e ) {
385+ logger .error (
386+ "Error during maxCount validation for move operation: {}. Proceeding without"
387+ + " validation." ,
388+ e .getMessage (),
389+ e );
390+ // Don't block the move operation if validation fails
391+ }
392+
393+ return failedAttachments ;
394+ }
395+
264396 /**
265397 * Updates database with moved attachments and cleans up source entity metadata.
266398 *
@@ -631,6 +763,73 @@ private void handleValidationFailure(
631763 failedAttachments .add (failure );
632764 }
633765
766+ /**
767+ * Parses SDM error message to extract meaningful failure reason.
768+ *
769+ * @param errorMessage The raw error message from SDM
770+ * @return A user-friendly failure reason
771+ */
772+ private String parseSDMErrorMessage (String errorMessage ) {
773+ if (errorMessage == null || errorMessage .isEmpty ()) {
774+ return SDMConstants .SDM_MOVE_OPERATION_FAILED ;
775+ }
776+
777+ // Check for nameConstraintViolation (duplicate file)
778+ if (errorMessage .contains ("nameConstraintViolation" )) {
779+ // Extract the meaningful part: "Child 6.pdf with Id ... already exists"
780+ int colonIndex = errorMessage .indexOf (":" );
781+ if (colonIndex != -1 && colonIndex + 1 < errorMessage .length ()) {
782+ return errorMessage .substring (colonIndex + 1 ).trim ();
783+ }
784+ }
785+
786+ // Check for other common SDM errors and extract meaningful parts
787+ // For now, return the full message as fallback
788+ return errorMessage ;
789+ }
790+
791+ /**
792+ * Builds a detailed validation failure message including invalid properties if available.
793+ *
794+ * @param moveContext The move context containing validation state
795+ * @param exception The exception that occurred
796+ * @return A detailed failure message
797+ */
798+ private String buildValidationFailureMessage (
799+ AttachmentMoveContext moveContext , Exception exception ) {
800+ // Check if we have invalid properties information in the context
801+ if (moveContext .getInvalidProperties () != null
802+ && !moveContext .getInvalidProperties ().isEmpty ()) {
803+ return SDMConstants .INVALID_SECONDARY_PROPERTIES_PREFIX
804+ + String .join (", " , moveContext .getInvalidProperties ())
805+ + SDMConstants .INVALID_SECONDARY_PROPERTIES_SUFFIX ;
806+ }
807+
808+ // Detect specific failure types and provide meaningful messages
809+ String exceptionType = exception .getClass ().getSimpleName ();
810+ String exceptionMessage = exception .getMessage ();
811+
812+ // Database fetch failure
813+ if (exceptionType .contains ("ServiceException" )
814+ || exceptionMessage != null && exceptionMessage .contains ("database" )) {
815+ return "Failed to retrieve attachment metadata from database. Attachment rolled back to source." ;
816+ }
817+
818+ // JSON parsing failure
819+ if (exceptionType .contains ("JSONException" )
820+ || exceptionMessage != null && exceptionMessage .contains ("JSON" )) {
821+ return "Failed to parse SDM response. Attachment rolled back to source." ;
822+ }
823+
824+ // Processing/other failures with specific message
825+ if (exceptionMessage != null && !exceptionMessage .isEmpty ()) {
826+ return "Processing failed: " + exceptionMessage + ". Attachment rolled back to source." ;
827+ }
828+
829+ // Generic fallback
830+ return "Attachment processing failed. Attachment rolled back to source." ;
831+ }
832+
634833 /**
635834 * Processes a single attachment move: Move in SDM → Validate → Process or Rollback.
636835 *
@@ -723,6 +922,8 @@ private void processSingleAttachmentMove(AttachmentMoveContext moveContext) {
723922
724923 if (!invalidProperties .isEmpty ()) {
725924 // Step 3a: Validation failed → Rollback immediately
925+ // Store invalid properties in context for detailed error message
926+ moveContext .setInvalidProperties (invalidProperties );
726927 handleValidationFailure (
727928 moveContext .getObjectId (),
728929 invalidProperties ,
@@ -757,10 +958,9 @@ private void processSingleAttachmentMove(AttachmentMoveContext moveContext) {
757958 e .getMessage ());
758959 Map <String , String > failure = new HashMap <>();
759960 failure .put (OBJECT_ID_KEY , moveContext .getObjectId ());
760- // Pass through the actual SDM error message for clarity
761- failure .put (
762- FAILURE_REASON_KEY ,
763- e .getMessage () != null ? e .getMessage () : SDMConstants .SDM_MOVE_OPERATION_FAILED );
961+ // Parse SDM error message to extract meaningful failure reason
962+ String failureReason = parseSDMErrorMessage (e .getMessage ());
963+ failure .put (FAILURE_REASON_KEY , failureReason );
764964 moveContext .addFailedAttachment (failure );
765965 } catch (Exception e ) {
766966 // Validation/processing failed
@@ -771,11 +971,8 @@ private void processSingleAttachmentMove(AttachmentMoveContext moveContext) {
771971 e );
772972 Map <String , String > failure = new HashMap <>();
773973 failure .put (OBJECT_ID_KEY , moveContext .getObjectId ());
774- // Provide detailed validation error with context
775- String detailedReason =
776- e .getMessage () != null && !e .getMessage ().isEmpty ()
777- ? SDMConstants .VALIDATION_FAILED_PREFIX + e .getMessage ()
778- : SDMConstants .VALIDATION_FAILED_DEFAULT_MESSAGE ;
974+ // Provide detailed validation error with invalid properties if available
975+ String detailedReason = buildValidationFailureMessage (moveContext , e );
779976 failure .put (FAILURE_REASON_KEY , detailedReason );
780977 moveContext .addFailedAttachment (failure );
781978 }
0 commit comments