From 863e60b88736918d7b56ffb1bf93e6fd938447d2 Mon Sep 17 00:00:00 2001 From: "Yashmeet ." Date: Fri, 27 Mar 2026 14:07:18 +0530 Subject: [PATCH] App changes for attachment creation in Active entity --- cap-notebook/demoapp/app/common.cds | 74 +++++- cap-notebook/demoapp/srv/admin-service.cds | 18 ++ .../demoapp/handlers/AdminServiceHandler.java | 230 ++++++++++++++++++ 3 files changed, 321 insertions(+), 1 deletion(-) create mode 100644 cap-notebook/demoapp/srv/src/main/java/customer/demoapp/handlers/AdminServiceHandler.java diff --git a/cap-notebook/demoapp/app/common.cds b/cap-notebook/demoapp/app/common.cds index 0f859fdfd..198a18c9e 100644 --- a/cap-notebook/demoapp/app/common.cds +++ b/cap-notebook/demoapp/app/common.cds @@ -64,6 +64,14 @@ annotate my.Books.attachments with @UI: { TypeNamePlural: '{i18n>Attachments}', }, LineItem : [ + { + $Type : 'UI.DataFieldForAction', + Action: 'AdminService.createAttachmentInActive', + Label : 'Create Attachment', + Inline: false, + RequiresSelection: false, + ![@UI.Hidden]: {$edmJson: {$Ne: [ {$Path: 'IsActiveEntity'}, true ]}} + }, {Value: type, @HTML5.CssDefaults: {width: '10%'}}, {Value: fileName, @HTML5.CssDefaults: {width: '20%'}}, {Value: content, @HTML5.CssDefaults: {width: '0%'}}, @@ -150,7 +158,15 @@ annotate my.Books.references with @UI: { TypeNamePlural: '{i18n>Attachments}', }, LineItem : [ - {Value: type, @HTML5.CssDefaults: {width: '10%'}}, + { + $Type : 'UI.DataFieldForAction', + Action: 'AdminService.createAttachmentInActive', + Label : 'Create Attachment', + Inline: false, + RequiresSelection: false, + ![@UI.Hidden]: {$edmJson: {$Ne: [ {$Path: 'IsActiveEntity'}, true ]}} + }, + {Value: type, @HTML5.CssDefaults: {width: '10%'}}, {Value: fileName, @HTML5.CssDefaults: {width: '20%'}}, {Value: content, @HTML5.CssDefaults: {width: '0%'}}, {Value: createdAt, @HTML5.CssDefaults: {width: '15%'}}, @@ -231,6 +247,14 @@ annotate my.Books.footnotes with @UI: { TypeNamePlural: '{i18n>Attachments}', }, LineItem : [ + { + $Type : 'UI.DataFieldForAction', + Action: 'AdminService.createAttachmentInActive', + Label : 'Create Attachment', + Inline: false, + RequiresSelection: false, + ![@UI.Hidden]: {$edmJson: {$Ne: [ {$Path: 'IsActiveEntity'}, true ]}} + }, {Value: type, @HTML5.CssDefaults: {width: '10%'}}, {Value: fileName, @HTML5.CssDefaults: {width: '20%'}}, {Value: content, @HTML5.CssDefaults: {width: '0%'}}, @@ -312,6 +336,14 @@ annotate my.Chapters.attachments with @UI: { TypeNamePlural: '{i18n>Attachments}', }, LineItem : [ + { + $Type : 'UI.DataFieldForAction', + Action: 'AdminService.createAttachmentInActive', + Label : 'Create Attachment', + Inline: false, + RequiresSelection: false, + ![@UI.Hidden]: {$edmJson: {$Ne: [ {$Path: 'IsActiveEntity'}, true ]}} + }, {Value: type, @HTML5.CssDefaults: {width: '10%'}}, {Value: fileName, @HTML5.CssDefaults: {width: '20%'}}, {Value: content, @HTML5.CssDefaults: {width: '0%'}}, @@ -393,6 +425,14 @@ annotate my.Chapters.references with @UI: { TypeNamePlural: '{i18n>References}', }, LineItem : [ + { + $Type : 'UI.DataFieldForAction', + Action: 'AdminService.createAttachmentInActive', + Label : 'Create Attachment', + Inline: false, + RequiresSelection: false, + ![@UI.Hidden]: {$edmJson: {$Ne: [ {$Path: 'IsActiveEntity'}, true ]}} + }, {Value: type, @HTML5.CssDefaults: {width: '10%'}}, {Value: fileName, @HTML5.CssDefaults: {width: '20%'}}, {Value: content, @HTML5.CssDefaults: {width: '0%'}}, @@ -474,6 +514,14 @@ annotate my.Chapters.footnotes with @UI: { TypeNamePlural: '{i18n>Footnotes}', }, LineItem : [ + { + $Type : 'UI.DataFieldForAction', + Action: 'AdminService.createAttachmentInActive', + Label : 'Create Attachment', + Inline: false, + RequiresSelection: false, + ![@UI.Hidden]: {$edmJson: {$Ne: [ {$Path: 'IsActiveEntity'}, true ]}} + }, {Value: type, @HTML5.CssDefaults: {width: '10%'}}, {Value: fileName, @HTML5.CssDefaults: {width: '20%'}}, {Value: content, @HTML5.CssDefaults: {width: '0%'}}, @@ -555,6 +603,14 @@ annotate my.Pages.attachments with @UI: { TypeNamePlural: '{i18n>Attachments}', }, LineItem : [ + { + $Type : 'UI.DataFieldForAction', + Action: 'AdminService.createAttachmentInActive', + Label : 'Create Attachment', + Inline: false, + RequiresSelection: false, + ![@UI.Hidden]: {$edmJson: {$Ne: [ {$Path: 'IsActiveEntity'}, true ]}} + }, {Value: type, @HTML5.CssDefaults: {width: '10%'}}, {Value: fileName, @HTML5.CssDefaults: {width: '20%'}}, {Value: content, @HTML5.CssDefaults: {width: '0%'}}, @@ -636,6 +692,14 @@ annotate my.Pages.references with @UI: { TypeNamePlural: '{i18n>References}', }, LineItem : [ + { + $Type : 'UI.DataFieldForAction', + Action: 'AdminService.createAttachmentInActive', + Label : 'Create Attachment', + Inline: false, + RequiresSelection: false, + ![@UI.Hidden]: {$edmJson: {$Ne: [ {$Path: 'IsActiveEntity'}, true ]}} + }, {Value: type, @HTML5.CssDefaults: {width: '10%'}}, {Value: fileName, @HTML5.CssDefaults: {width: '20%'}}, {Value: content, @HTML5.CssDefaults: {width: '0%'}}, @@ -717,6 +781,14 @@ annotate my.Pages.footnotes with @UI: { TypeNamePlural: '{i18n>Footnotes}', }, LineItem : [ + { + $Type : 'UI.DataFieldForAction', + Action: 'AdminService.createAttachmentInActive', + Label : 'Create Attachment', + Inline: false, + RequiresSelection: false, + ![@UI.Hidden]: {$edmJson: {$Ne: [ {$Path: 'IsActiveEntity'}, true ]}} + }, {Value: type, @HTML5.CssDefaults: {width: '10%'}}, {Value: fileName, @HTML5.CssDefaults: {width: '20%'}}, {Value: content, @HTML5.CssDefaults: {width: '0%'}}, diff --git a/cap-notebook/demoapp/srv/admin-service.cds b/cap-notebook/demoapp/srv/admin-service.cds index 7a97d6e98..5ebfc9404 100644 --- a/cap-notebook/demoapp/srv/admin-service.cds +++ b/cap-notebook/demoapp/srv/admin-service.cds @@ -14,6 +14,8 @@ service AdminService @(requires: ['admin','system-user']) { entity Books.attachments as projection on my.Books.attachments actions { @(Common.SideEffects : {TargetEntities: ['']},) + action createAttachmentInActive(in:many $self); + @(Common.SideEffects : {TargetEntities: ['']},) action copyAttachments(in:many $self, up__ID:String, objectIds:String); // moveAttachments action signature @(Common.SideEffects : {TargetEntities: ['']}) @@ -43,6 +45,8 @@ service AdminService @(requires: ['admin','system-user']) { entity Books.references as projection on my.Books.references actions { @(Common.SideEffects : {TargetEntities: ['']},) + action createAttachmentInActive(in:many $self); + @(Common.SideEffects : {TargetEntities: ['']},) action copyAttachments(in:many $self, up__ID:String, objectIds:String); // moveAttachments action signature @(Common.SideEffects : {TargetEntities: ['']}) @@ -72,6 +76,8 @@ service AdminService @(requires: ['admin','system-user']) { entity Books.footnotes as projection on my.Books.footnotes actions { @(Common.SideEffects : {TargetEntities: ['']},) + action createAttachmentInActive(in:many $self); + @(Common.SideEffects : {TargetEntities: ['']},) action copyAttachments(in:many $self, up__ID:String, objectIds:String); // moveAttachments action signature @(Common.SideEffects : {TargetEntities: ['']}) @@ -101,6 +107,8 @@ service AdminService @(requires: ['admin','system-user']) { entity Pages.attachments as projection on my.Pages.attachments actions { @(Common.SideEffects : {TargetEntities: ['']},) + action createAttachmentInActive(in:many $self); + @(Common.SideEffects : {TargetEntities: ['']},) action copyAttachments(in:many $self, up__ID:String, objectIds:String); // moveAttachments action signature @(Common.SideEffects : {TargetEntities: ['']}) @@ -130,6 +138,8 @@ service AdminService @(requires: ['admin','system-user']) { entity Pages.references as projection on my.Pages.references actions { @(Common.SideEffects : {TargetEntities: ['']},) + action createAttachmentInActive(in:many $self); + @(Common.SideEffects : {TargetEntities: ['']},) action copyAttachments(in:many $self, up__ID:String, objectIds:String); // moveAttachments action signature @(Common.SideEffects : {TargetEntities: ['']}) @@ -159,6 +169,8 @@ service AdminService @(requires: ['admin','system-user']) { entity Chapters.attachments as projection on my.Chapters.attachments actions { @(Common.SideEffects : {TargetEntities: ['']},) + action createAttachmentInActive(in:many $self); + @(Common.SideEffects : {TargetEntities: ['']},) action copyAttachments(in:many $self, up__ID:String, objectIds:String); // moveAttachments action signature @(Common.SideEffects : {TargetEntities: ['']}) @@ -188,6 +200,8 @@ service AdminService @(requires: ['admin','system-user']) { entity Chapters.references as projection on my.Chapters.references actions { @(Common.SideEffects : {TargetEntities: ['']},) + action createAttachmentInActive(in:many $self); + @(Common.SideEffects : {TargetEntities: ['']},) action copyAttachments(in:many $self, up__ID:String, objectIds:String); // moveAttachments action signature @(Common.SideEffects : {TargetEntities: ['']}) @@ -217,6 +231,8 @@ service AdminService @(requires: ['admin','system-user']) { entity Chapters.footnotes as projection on my.Chapters.footnotes actions { @(Common.SideEffects : {TargetEntities: ['']},) + action createAttachmentInActive(in:many $self); + @(Common.SideEffects : {TargetEntities: ['']},) action copyAttachments(in:many $self, up__ID:String, objectIds:String); // moveAttachments action signature @(Common.SideEffects : {TargetEntities: ['']}) @@ -247,6 +263,8 @@ service AdminService @(requires: ['admin','system-user']) { entity Pages.footnotes as projection on my.Pages.footnotes actions { @(Common.SideEffects : {TargetEntities: ['']},) + action createAttachmentInActive(in:many $self); + @(Common.SideEffects : {TargetEntities: ['']},) action copyAttachments(in:many $self, up__ID:String, objectIds:String); // moveAttachments action signature @(Common.SideEffects : {TargetEntities: ['']}) diff --git a/cap-notebook/demoapp/srv/src/main/java/customer/demoapp/handlers/AdminServiceHandler.java b/cap-notebook/demoapp/srv/src/main/java/customer/demoapp/handlers/AdminServiceHandler.java new file mode 100644 index 000000000..4281f4957 --- /dev/null +++ b/cap-notebook/demoapp/srv/src/main/java/customer/demoapp/handlers/AdminServiceHandler.java @@ -0,0 +1,230 @@ +package customer.demoapp.handlers; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Component; + +import com.sap.cds.ql.Insert; +import com.sap.cds.ql.cqn.CqnAnalyzer; +import com.sap.cds.ql.cqn.CqnComparisonPredicate; +import com.sap.cds.ql.cqn.CqnConnectivePredicate; +import com.sap.cds.ql.cqn.CqnElementRef; +import com.sap.cds.ql.cqn.CqnLiteral; +import com.sap.cds.ql.cqn.CqnPredicate; +import com.sap.cds.ql.cqn.CqnReference; +import com.sap.cds.ql.cqn.CqnSelect; +import com.sap.cds.ql.cqn.CqnStructuredTypeRef; +import com.sap.cds.services.EventContext; +import com.sap.cds.services.cds.ApplicationService; +import com.sap.cds.services.handler.EventHandler; +import com.sap.cds.services.handler.annotations.On; +import com.sap.cds.services.handler.annotations.ServiceName; + +import java.util.List; + +import cds.gen.adminservice.AdminService_; +import cds.gen.adminservice.BooksAttachments_; + +/** + * Handler for AdminService operations including creating attachments in active entity state. + */ +@Component +@ServiceName(AdminService_.CDS_NAME) +public class AdminServiceHandler implements EventHandler { + + private static final Logger logger = LoggerFactory.getLogger(AdminServiceHandler.class); + + @Autowired + @Qualifier("AdminService") + private ApplicationService adminService; + + /** + * Handler for createAttachmentInActive action. + */ + @On(event = "createAttachmentInActive") + public void createAttachmentInActive(EventContext context) { + String targetEntity = context.getTarget().getQualifiedName(); + logger.info("=== createAttachmentInActive triggered for entity: {} ===", targetEntity); + + // Guard: When CAP invokes this during draft activation (edit+save), + // the "in" parameter contains existing attachment rows. + // A user-initiated button click sends "in" as null/empty (RequiresSelection: false). + // Skip execution if "in" has items — that means it's a framework save, not a user click. + Object inParam = context.get("in"); + if (inParam instanceof List && !((List) inParam).isEmpty()) { + logger.info("Skipping createAttachmentInActive — triggered by framework save (in has {} items), not user click", ((List) inParam).size()); + context.setCompleted(); + return; + } + + try { + // Extract the parent entity ID from CQN + CqnSelect cqn = (CqnSelect) context.get("cqn"); + CqnAnalyzer analyzer = CqnAnalyzer.create(context.getModel()); + Map rootKeys = analyzer.analyze(cqn).rootKeys(); + logger.info("Root keys: {}", rootKeys); + + // Extract the immediate parent ID. + // For non-nested entities (e.g., Books.attachments), rootKeys has {up__ID: bookID}. + // For nested entities (e.g., Chapters.attachments via composition path + // Books(bookID)/chapters(chapterID)/attachments), rootKeys only has {ID: bookID} + // and the Chapter ID is in the penultimate CQN path segment's filter. + String parentId = extractParentId(cqn, rootKeys); + + if (parentId == null || parentId.isEmpty()) { + logger.error("Could not extract parent ID from CQN. Root keys: {}", rootKeys); + context.setCompleted(); + throw new RuntimeException("Parent entity ID is required to create attachment."); + } + + logger.info("Creating attachment for parent ID: {} in facet: {}", parentId, targetEntity); + + // Create attachment with unique filename (timestamp prevents any duplicate issues) + String attachmentId = createAttachmentWithContent(parentId, targetEntity); + logger.info("Attachment created successfully with ID: {}", attachmentId); + + context.setCompleted(); + + } catch (Exception e) { + logger.error("Failed to create attachment: {}", e.getMessage(), e); + context.setCompleted(); + throw new RuntimeException("Failed to create attachment: " + e.getMessage(), e); + } + } + + /** + * Creates an attachment record with content in the active entity state. + * Uses a timestamp-based unique filename to prevent duplicate issues. + */ + private String createAttachmentWithContent(String parentId, String targetEntity) throws IOException { + String attachmentId = UUID.randomUUID().toString(); + + // Use timestamp in filename to guarantee uniqueness + String timestamp = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss-SSS") + .withZone(ZoneId.systemDefault()) + .format(Instant.now()); + String fileName = "attachment-" + timestamp + ".txt"; + + String sampleContent = "Sample Attachment\n" + + "Created: " + Instant.now() + "\n" + + "Parent ID: " + parentId + "\n" + + "Facet: " + targetEntity + "\n" + + "Attachment ID: " + attachmentId; + + InputStream contentStream = new ByteArrayInputStream(sampleContent.getBytes(StandardCharsets.UTF_8)); + + Map attachmentData = new HashMap<>(); + attachmentData.put("ID", attachmentId); + attachmentData.put("up__ID", parentId); + attachmentData.put("fileName", fileName); + attachmentData.put("mimeType", "text/plain"); + attachmentData.put("note", "Created programmatically in active entity"); + attachmentData.put("content", contentStream); + + // Determine which entity to insert into based on the target + // The target will be like "AdminService.Books.attachments" or "AdminService.Chapters.attachments" etc. + String insertTarget = targetEntity; + logger.info("Inserting attachment into: {}", insertTarget); + + Insert insert = Insert.into(insertTarget).entry(attachmentData); + adminService.run(insert); + + return attachmentId; + } + + /** + * Extracts the immediate parent entity's ID from the CQN. + * + * For non-nested entities (e.g., Books.attachments): + * CQN: SELECT from AdminService.Books.attachments WHERE up__ID = 'bookID' + * rootKeys = {up__ID: bookID} → up__ID found directly. + * + * For nested entities (e.g., Chapters.attachments via composition path): + * CQN: SELECT from AdminService.Books[ID='bookID'].chapters[ID='chapterID'].attachments + * rootKeys = {ID: bookID} → up__ID NOT found. + * Fix: traverse CQN path segments and extract ID from the penultimate segment + * (which represents the immediate parent entity, e.g., chapters[ID='chapterID']). + */ + private String extractParentId(CqnSelect cqn, Map rootKeys) { + // Case 1: Direct entity set — rootKeys has up__ID + Object upId = rootKeys.get("up__ID"); + if (upId != null) { + logger.info("Found up__ID in rootKeys: {}", upId); + return upId.toString(); + } + + // Case 2: Nested composition path — traverse CQN ref segments + try { + if (cqn.from().isRef()) { + CqnStructuredTypeRef ref = cqn.from().asRef(); + List segments = ref.segments(); + logger.info("CQN path has {} segments", segments.size()); + + if (segments.size() >= 2) { + // Penultimate segment is the immediate parent (e.g., "chapters") + CqnReference.Segment parentSegment = segments.get(segments.size() - 2); + logger.info("Parent segment: {}, has filter: {}", + parentSegment.id(), parentSegment.filter().isPresent()); + + if (parentSegment.filter().isPresent()) { + String parentId = extractIdFromPredicate(parentSegment.filter().get()); + if (parentId != null) { + logger.info("Extracted parent ID from CQN segment '{}': {}", + parentSegment.id(), parentId); + return parentId; + } + } + } + } + } catch (Exception e) { + logger.warn("Could not extract parent ID from CQN path segments: {}", e.getMessage()); + } + + // Fallback: use ID from rootKeys (correct for non-nested, wrong for nested) + Object id = rootKeys.get("ID"); + if (id != null) { + logger.warn("Using fallback ID from rootKeys (may be wrong for nested entities): {}", id); + return id.toString(); + } + + return null; + } + + /** + * Recursively extracts the "ID" value from a CQN predicate. + * Handles simple comparisons (ID = 'value') and conjunctions (ID = 'value' AND IsActiveEntity = true). + */ + private String extractIdFromPredicate(CqnPredicate predicate) { + if (predicate instanceof CqnComparisonPredicate) { + CqnComparisonPredicate comp = (CqnComparisonPredicate) predicate; + if (comp.left() instanceof CqnElementRef && comp.right() instanceof CqnLiteral) { + String fieldName = ((CqnElementRef) comp.left()).lastSegment(); + if ("ID".equals(fieldName)) { + return ((CqnLiteral) comp.right()).value().toString(); + } + } + } else if (predicate instanceof CqnConnectivePredicate) { + // Conjunction (AND) or disjunction (OR) — check each child + for (CqnPredicate child : ((CqnConnectivePredicate) predicate).predicates()) { + String id = extractIdFromPredicate(child); + if (id != null) { + return id; + } + } + } + return null; + } +}