diff --git a/pom.xml b/pom.xml index 13baf0765..a71a54c5b 100644 --- a/pom.xml +++ b/pom.xml @@ -23,7 +23,7 @@ - 1.7.1-SNAPSHOT + 1.0.0-RC1 17 ${java.version} ${java.version} diff --git a/sdm/src/main/java/com/sap/cds/sdm/handler/applicationservice/helper/AttachmentsHandlerUtils.java b/sdm/src/main/java/com/sap/cds/sdm/handler/applicationservice/helper/AttachmentsHandlerUtils.java index d9997cba9..958d4dde0 100644 --- a/sdm/src/main/java/com/sap/cds/sdm/handler/applicationservice/helper/AttachmentsHandlerUtils.java +++ b/sdm/src/main/java/com/sap/cds/sdm/handler/applicationservice/helper/AttachmentsHandlerUtils.java @@ -405,7 +405,7 @@ public static Map> getAttachmentCompositionDetails( // Get parent titles Map parentTitles = - getAttachmentParentTitles(targetEntity, entityData, compositionPathMapping); + getAttachmentParentTitles(model, targetEntity, entityData, compositionPathMapping); // Combine into comprehensive details for (Map.Entry entry : compositionPathMapping.entrySet()) { @@ -432,6 +432,7 @@ public static Map> getAttachmentCompositionDetails( * composition. It handles both direct attachments at the root level and nested attachments within * composed entities. * + * @param model the CDS model containing entity definitions and relationships * @param targetEntity the qualified name of the target entity (e.g., "AdminService.Books") * @param entity the entity data structure containing potential attachment information * @param compositionPathMapping the mapping of attachment composition paths obtained from @@ -440,7 +441,10 @@ public static Map> getAttachmentCompositionDetails( * titles, or an empty map if no attachments are found */ public static Map getAttachmentParentTitles( - String targetEntity, Map entity, Map compositionPathMapping) { + CdsModel model, + String targetEntity, + Map entity, + Map compositionPathMapping) { Map parentTitles = new HashMap<>(); String[] targetEntityPath = targetEntity.split("\\."); @@ -449,8 +453,9 @@ public static Map getAttachmentParentTitles( for (Map.Entry compositionEntry : compositionPathMapping.entrySet()) { String compositionPath = compositionEntry.getValue(); - String parentTitle = findParentTitle(wrappedEntity, compositionPath, entityName); - if (parentTitle != null) { + String parentTitle = + findParentTitle(model, wrappedEntity, compositionPath, entityName, targetEntity); + if (parentTitle != null && !parentTitle.isEmpty()) { parentTitles.put(compositionPath, parentTitle); } } @@ -461,86 +466,453 @@ public static Map getAttachmentParentTitles( /** * Finds the parent title for a given attachment composition path. * + * @param model the CDS model containing entity definitions and relationships * @param entity the wrapped entity data structure * @param compositionPath the composition path (e.g., "AdminService.chapters123.attachments" or * "AdminService.Books.references") * @param rootEntityName the name of the root entity + * @param targetEntity the qualified name of the target entity * @return the title of the parent entity containing the attachment composition, or null if not * found */ private static String findParentTitle( - Map entity, String compositionPath, String rootEntityName) { + CdsModel model, + Map entity, + String compositionPath, + String rootEntityName, + String targetEntity) { + logFindParentTitleStart(entity, compositionPath, rootEntityName, targetEntity); + try { String[] pathParts = compositionPath.split("\\."); + logger.info("findParentTitle: pathParts={}", String.join(",", pathParts)); - if (pathParts.length >= 3) { - String entityPart = pathParts[pathParts.length - 2]; // Second to last part (entity name) + if (pathParts.length < 3) { + logger.info("findParentTitle: Returning null - insufficient path parts"); + return null; + } - // Check if this is a direct composition (entity matches root entity) - if (entityPart.equalsIgnoreCase(rootEntityName)) { - // Direct attachment at root level (e.g., "AdminService.Books.references") - return extractTitleFromEntity(entity.get(rootEntityName)); - } else { - // Nested attachment (e.g., "AdminService.chapters123.attachments") - // Navigate to the parent entity - Object rootEntity = entity.get(rootEntityName); - if (rootEntity instanceof Map) { - @SuppressWarnings("unchecked") - Map rootMap = (Map) rootEntity; - Object parentCollection = rootMap.get(entityPart); - - if (parentCollection instanceof List) { - @SuppressWarnings("unchecked") - List> parentList = (List>) parentCollection; - if (!parentList.isEmpty()) { - // Get title from the first item in the collection - return extractTitleFromEntity(parentList.get(0)); - } - } - } - } + String entityPart = pathParts[pathParts.length - 2]; + logger.info("findParentTitle: entityPart={} (second to last)", entityPart); + + if (entityPart.equalsIgnoreCase(rootEntityName)) { + return handleDirectAttachment(model, entity, rootEntityName, targetEntity); + } else { + return handleNestedAttachment(model, entity, rootEntityName, entityPart, targetEntity); } } catch (Exception e) { logger.warn("Error finding parent title for composition path: " + compositionPath, e); + return null; } + } + /** + * Logs the start of findParentTitle operation. + * + * @param entity the entity data structure + * @param compositionPath the composition path + * @param rootEntityName the root entity name + * @param targetEntity the target entity name + */ + private static void logFindParentTitleStart( + Map entity, + String compositionPath, + String rootEntityName, + String targetEntity) { + logger.info( + "findParentTitle: compositionPath={}, rootEntityName={}, targetEntity={}", + compositionPath, + rootEntityName, + targetEntity); + logger.info("findParentTitle: entity keys={}", entity.keySet()); + } + + /** + * Handles direct attachment title extraction. + * + * @param model the CDS model + * @param entity the entity data structure + * @param rootEntityName the root entity name + * @param targetEntity the target entity name + * @return the extracted title, or null if not found + */ + private static String handleDirectAttachment( + CdsModel model, Map entity, String rootEntityName, String targetEntity) { + logger.info( + "findParentTitle: Direct attachment detected, looking up entity.get({})", rootEntityName); + Object entityData = entity.get(rootEntityName); + logger.info( + "findParentTitle: entityData type={}, isNull={}", + entityData != null ? entityData.getClass().getSimpleName() : "null", + entityData == null); + return extractTitleFromEntity(model, targetEntity, entityData); + } + + /** + * Handles nested attachment title extraction. + * + * @param model the CDS model + * @param entity the entity data structure + * @param rootEntityName the root entity name + * @param entityPart the entity part from the path + * @param targetEntity the target entity name + * @return the extracted title, or null if not found + */ + private static String handleNestedAttachment( + CdsModel model, + Map entity, + String rootEntityName, + String entityPart, + String targetEntity) { + logger.info("findParentTitle: Nested attachment detected"); + + Object rootEntity = entity.get(rootEntityName); + if (!(rootEntity instanceof Map)) { + logger.info("findParentTitle: Returning null - rootEntity is not a Map"); + return null; + } + + @SuppressWarnings("unchecked") + Map rootMap = (Map) rootEntity; + Object parentCollection = rootMap.get(entityPart); + + if (!(parentCollection instanceof List)) { + logger.info("findParentTitle: Returning null - parentCollection is not a List"); + return null; + } + + @SuppressWarnings("unchecked") + List> parentList = (List>) parentCollection; + if (parentList.isEmpty()) { + logger.info("findParentTitle: Returning null - parentList is empty"); + return null; + } + + String nestedEntityName = determineNestedEntityName(model, targetEntity, entityPart); + if (nestedEntityName == null) { + logger.info("findParentTitle: Returning null - nestedEntityName is null"); + return null; + } + + return extractTitleFromEntity(model, nestedEntityName, parentList.get(0)); + } + + /** + * Determines the fully qualified entity name for a nested composition. + * + * @param model the CDS model + * @param parentEntityName the parent entity name + * @param compositionName the composition property name + * @return the fully qualified nested entity name, or null if not found + */ + private static String determineNestedEntityName( + CdsModel model, String parentEntityName, String compositionName) { + try { + Optional parentEntity = model.findEntity(parentEntityName); + if (parentEntity.isPresent()) { + Optional composition = + parentEntity.get().findElement(compositionName); + if (composition.isPresent() && composition.get().getType().isAssociation()) { + CdsAssociationType associationType = (CdsAssociationType) composition.get().getType(); + return associationType.getTarget().getQualifiedName(); + } + } + } catch (Exception e) { + logger.warn("Error determining nested entity name for composition: " + compositionName, e); + } return null; } /** - * Extracts the title field from an entity object, with fallback options. + * Extracts the title field from an entity object using CDS metadata annotations. * + *

This method extracts entity titles using @Common.Text annotation on the semantic key field, + * which is the only mechanism proven to work reliably in both Fiori UI and Java backend through + * empirical testing. + * + *

How it works: + * + *

    + *
  1. Finds the semantic key field from @Common.SemanticKey annotation + *
  2. Checks if that field has a @Common.Text annotation pointing to a title field + *
  3. Extracts and returns the value of the title field + *
+ * + *

Important: Define your CDS model as follows for proper title extraction: + * + *

{@code
+   * entity Books {
+   *   key ID : UUID;
+   *   title  : String;
+   * }
+   *
+   * annotate Books with @Common.SemanticKey: [ID] {
+   *   ID @Common.Text: title;
+   * }
+   * }
+ * + *

Note: UI.HeaderInfo.Title annotations defined in app/common.cds are NOT accessible to + * Java backend code via CDS Reflection API. They are only used by Fiori UI layer for OData + * metadata generation. + * + * @param model the CDS model containing entity definitions and annotations + * @param entityName the qualified name of the entity (e.g., "AdminService.Books") * @param entityObj the entity object to extract title from - * @return the title string, or a fallback identifier, or null if not found + * @return the title string from annotations, or null if not found */ - private static String extractTitleFromEntity(Object entityObj) { + private static String extractTitleFromEntity( + CdsModel model, String entityName, Object entityObj) { if (!(entityObj instanceof Map)) { + logger.info("extractTitleFromEntity: entityObj is not a Map for entity: {}", entityName); return null; } @SuppressWarnings("unchecked") Map entityMap = (Map) entityObj; - // Priority order: title -> name -> ID -> first non-null string value - String[] titleFields = {"title", "name", "ID", "id"}; + logger.info( + "extractTitleFromEntity: Extracting title for entity: {}, data keys: {}", + entityName, + entityMap.keySet()); + + // Get title field from Common.Text annotation on semantic key field + // This is proven to work in both Fiori UI and Java backend + String titleFieldFromSemanticKey = getSemanticKeyField(model, entityName); + logger.info( + "extractTitleFromEntity: titleFieldFromSemanticKey = {} for entity: {}", + titleFieldFromSemanticKey, + entityName); + + if (titleFieldFromSemanticKey != null) { + // Check if the semantic key field has a Common.Text annotation pointing to another field + String titleFieldFromCommonText = + getTitleFromCommonTextOnField(model, entityName, titleFieldFromSemanticKey); + logger.info( + "extractTitleFromEntity: titleFieldFromCommonText = {} for entity: {}", + titleFieldFromCommonText, + entityName); + + if (titleFieldFromCommonText != null) { + // Use the field specified by Common.Text annotation + Object value = getNestedValue(entityMap, titleFieldFromCommonText); + logger.info( + "extractTitleFromEntity: Value for Common.Text field '{}' = {}", + titleFieldFromCommonText, + value); + if (value != null && value instanceof String && !((String) value).trim().isEmpty()) { + logger.info( + "extractTitleFromEntity: Returning title from Common.Text annotation: {}", value); + return (String) value; + } + } + } + + logger.info("extractTitleFromEntity: No title found for entity: {}", entityName); + // Return null if no annotation-based title is found + return null; + } + + /** + * Extracts the title field name from @Common.Text annotation on a specific field. This mirrors + * how Fiori determines page titles when UI.HeaderInfo is not present. + * + *

Example: If field "ID" has @Common.Text: title, this returns "title" + * + * @param model the CDS model + * @param entityName the qualified entity name + * @param fieldName the field to check for @Common.Text annotation + * @return the field name from Common.Text annotation, or null if not found + */ + private static String getTitleFromCommonTextOnField( + CdsModel model, String entityName, String fieldName) { + logger.info( + "getTitleFromCommonTextOnField: Checking field '{}' on entity '{}'", fieldName, entityName); + + if (model == null || entityName == null || fieldName == null) { + return null; + } + + try { + Optional entityOpt = model.findEntity(entityName); + if (!entityOpt.isPresent()) { + return null; + } + + Optional elementOpt = entityOpt.get().findElement(fieldName); + if (!elementOpt.isPresent()) { + return null; + } - for (String field : titleFields) { - Object value = entityMap.get(field); - if (value != null && value instanceof String && !((String) value).trim().isEmpty()) { - return (String) value; + com.sap.cds.reflect.CdsElement element = elementOpt.get(); + logger.info( + "getTitleFromCommonTextOnField: Found element '{}', checking for Common annotation", + fieldName); + + // Try Common annotation first (contains Text property) + String result = extractTextFromCommonAnnotation(element); + if (result != null) { + return result; } + + // Try Common.Text directly as alternate format + return extractTextFromCommonTextAnnotation(element); + + } catch (Exception e) { + logger.info("getTitleFromCommonTextOnField: Error - {}", e.getMessage(), e); + return null; } + } - // Fallback: find any string value - for (Object value : entityMap.values()) { - if (value != null && value instanceof String && !((String) value).trim().isEmpty()) { - return (String) value; + /** + * Extracts text value from Common annotation's Text property. + * + * @param element the CDS element to check + * @return the parsed text field name, or null if not found + */ + private static String extractTextFromCommonAnnotation(com.sap.cds.reflect.CdsElement element) { + Optional> commonAnnotationOpt = + element.findAnnotation("Common"); + if (!commonAnnotationOpt.isPresent()) { + return null; + } + + Object commonValue = commonAnnotationOpt.get().getValue(); + logger.info( + "getTitleFromCommonTextOnField: Common annotation value type = {}", + commonValue != null ? commonValue.getClass().getSimpleName() : "null"); + + if (!(commonValue instanceof Map)) { + return null; + } + + @SuppressWarnings("unchecked") + Map commonMap = (Map) commonValue; + logger.info("getTitleFromCommonTextOnField: Common map keys = {}", commonMap.keySet()); + + Object textValue = commonMap.get("Text"); + logger.info("getTitleFromCommonTextOnField: Text value = {}", textValue); + + return parseTextValue(textValue, "getTitleFromCommonTextOnField"); + } + + /** + * Extracts text value from Common.Text annotation directly. + * + * @param element the CDS element to check + * @return the parsed text field name, or null if not found + */ + private static String extractTextFromCommonTextAnnotation( + com.sap.cds.reflect.CdsElement element) { + Optional> commonTextOpt = + element.findAnnotation("Common.Text"); + if (!commonTextOpt.isPresent()) { + return null; + } + + Object textValue = commonTextOpt.get().getValue(); + logger.info("getTitleFromCommonTextOnField: Common.Text value = {}", textValue); + + return parseTextValue(textValue, "getTitleFromCommonTextOnField"); + } + + /** + * Parses a text value by removing CDS element reference markers. + * + * @param textValue the raw text value from annotation + * @param logContext context string for logging + * @return the parsed field name, or null if textValue is null + */ + private static String parseTextValue(Object textValue, String logContext) { + if (textValue == null) { + return null; + } + + String result = textValue.toString(); + // Remove CDS annotation wrapper syntax to extract the actual field name + if (result.startsWith("{==") && result.endsWith("}")) { + result = result.substring(3, result.length() - 1); + } else if (result.startsWith("{") && result.endsWith("}")) { + result = result.substring(1, result.length() - 1); + } + logger.info("{}: Parsed title field = {}", logContext, result); + return result; + } + + /** + * Extracts the first field from Common.SemanticKey annotation. + * + * @param model the CDS model + * @param entityName the qualified entity name + * @return the first semantic key field name, or null if not found + */ + private static String getSemanticKeyField(CdsModel model, String entityName) { + if (model == null || entityName == null) { + return null; + } + + try { + Optional entityOpt = model.findEntity(entityName); + if (entityOpt.isPresent()) { + CdsEntity entity = entityOpt.get(); + Optional> semanticKeyOpt = + entity.findAnnotation("Common.SemanticKey"); + + if (semanticKeyOpt.isPresent() && semanticKeyOpt.get().getValue() instanceof List) { + @SuppressWarnings("unchecked") + List keys = (List) semanticKeyOpt.get().getValue(); + if (!keys.isEmpty()) { + String rawValue = keys.get(0).toString(); + logger.info("getSemanticKeyField: Raw value from annotation = {}", rawValue); + + // Extract field name from CDS annotation format (e.g., curly braces with equals + // prefix) + String fieldName = rawValue; + if (rawValue.startsWith("{==") && rawValue.endsWith("}")) { + fieldName = rawValue.substring(3, rawValue.length() - 1); + } else if (rawValue.startsWith("{") && rawValue.endsWith("}")) { + fieldName = rawValue.substring(1, rawValue.length() - 1); + } + + logger.info("getSemanticKeyField: Parsed field name = {}", fieldName); + return fieldName; + } + } } + } catch (Exception e) { + logger.info("getSemanticKeyField: Error - {}", e.getMessage(), e); } return null; } + /** + * Gets a nested value from a map using a path (e.g., "author.name"). + * + * @param map the map to extract value from + * @param path the path to the value (can include dots for nested access) + * @return the value at the path, or null if not found + */ + private static Object getNestedValue(Map map, String path) { + if (path == null || map == null) { + return null; + } + + String[] parts = path.split("\\."); + Object current = map; + + for (String part : parts) { + if (current instanceof Map) { + @SuppressWarnings("unchecked") + Map currentMap = (Map) current; + current = currentMap.get(part); + } else { + return null; + } + } + + return current; + } + /** * Validates file names in the provided data for various constraints including whitespace, * restricted characters, and duplicates. @@ -824,6 +1196,7 @@ public static CmisDocument prepareCmisDocument( public static String getContextInfo(String compositionName, String parentTitle) { return String.format(SDMErrorMessages.CONTEXT_INFO_TABLE, compositionName) + String.format( - SDMErrorMessages.CONTEXT_INFO_PAGE, (parentTitle != null ? parentTitle : "Unknown")); + SDMErrorMessages.CONTEXT_INFO_PAGE, + (parentTitle != null && !parentTitle.trim().isEmpty() ? parentTitle : "Unknown")); } }