diff --git a/.github/actions/newrelease/action.yml b/.github/actions/newrelease/action.yml index 04296d557..965980797 100644 --- a/.github/actions/newrelease/action.yml +++ b/.github/actions/newrelease/action.yml @@ -34,8 +34,8 @@ runs: #./ensure-license.sh git config --global user.name 'github-actions[bot]' git config --global user.email 'github-actions[bot]@users.noreply.github.com' - git checkout -b develop + git checkout -b Release_v1.10.2 git add cap-notebook/version.txt git commit -am "Update version to $VERSION" - git push --set-upstream origin develop + git push --set-upstream origin Release_v1.10.2 shell: bash diff --git a/.github/workflows/main-build-and-deploy-oss.yml b/.github/workflows/main-build-and-deploy-oss.yml index 74bfb7326..c99c8f129 100644 --- a/.github/workflows/main-build-and-deploy-oss.yml +++ b/.github/workflows/main-build-and-deploy-oss.yml @@ -9,7 +9,7 @@ on: types: [ "released" ] permissions: - contents: read + contents: write jobs: diff --git a/cap-notebook/version.txt b/cap-notebook/version.txt new file mode 100644 index 000000000..5ad2491cf --- /dev/null +++ b/cap-notebook/version.txt @@ -0,0 +1 @@ +1.10.2 diff --git a/pom.xml b/pom.xml index c188cfe7d..43e0f514f 100644 --- a/pom.xml +++ b/pom.xml @@ -23,7 +23,7 @@ - 1.10.1-SNAPSHOT + 1.10.3.1-SNAPSHOT 21 ${java.version} ${java.version} diff --git a/sdm/src/main/java/com/sap/cds/sdm/handler/applicationservice/SDMReadAttachmentsHandler.java b/sdm/src/main/java/com/sap/cds/sdm/handler/applicationservice/SDMReadAttachmentsHandler.java index 241e28531..c203f9a8e 100644 --- a/sdm/src/main/java/com/sap/cds/sdm/handler/applicationservice/SDMReadAttachmentsHandler.java +++ b/sdm/src/main/java/com/sap/cds/sdm/handler/applicationservice/SDMReadAttachmentsHandler.java @@ -133,7 +133,7 @@ public void processBefore(CdsReadEventContext context) throws IOException { return; } setErrorMessagesInCache(context); - if (context.getTarget().getAnnotationValue(SDMConstants.ANNOTATION_IS_MEDIA_DATA, false)) { + if (SDMApplicationHandlerHelper.isMediaEntity(context.getTarget())) { try { // update the uploadStatus of all blank attachments with success this is for existing // attachments 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 f5bc8ee93..b9f71050e 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 @@ -194,7 +194,7 @@ private static void processAttachmentPaths( List attachmentPaths) { for (String attachmentPath : attachmentPaths) { String entityPath = buildEntityPath(entity, targetEntity, attachmentPath); - String actualPath = buildActualPath(entity, compositionName, attachmentPath); + String actualPath = buildActualPath(entity, compositionName, targetEntity, attachmentPath); // Only add the mapping if both paths are non-null and the key doesn't already exist // This preserves direct attachment mappings from being overwritten by nested ones @@ -272,8 +272,9 @@ private static String buildEntityPath( // Direct attachment: use parent entity path entityPath = parentEntity.getQualifiedName() + "." + attachmentPart; } else { - // Nested attachment: use target entity path to ensure uniqueness - entityPath = targetEntity.getQualifiedName() + "." + attachmentPart; + // Nested attachment: use attachmentPath as-is — it already encodes the owning entity + // (e.g. "AdminService.Sections.attachments"), ensuring uniqueness at any depth. + entityPath = attachmentPath; } return entityPath; } @@ -284,19 +285,34 @@ private static String buildEntityPath( } private static String buildActualPath( - CdsEntity parentEntity, String compositionPropertyName, String attachmentPath) { + CdsEntity parentEntity, + String compositionPropertyName, + CdsEntity targetEntity, + String attachmentPath) { try { String[] pathParts = attachmentPath.split("\\."); if (pathParts.length >= 3) { - // Get the attachment part (last part) String attachmentPart = pathParts[pathParts.length - 1]; - - // Build the new path using parent entity qualified name + composition property name - return parentEntity.getQualifiedName() - + "." - + compositionPropertyName - + "." - + attachmentPart; + String ownerEntityQN = attachmentPath.substring(0, attachmentPath.lastIndexOf('.')); + + if (ownerEntityQN.equals(targetEntity.getQualifiedName())) { + return parentEntity.getQualifiedName() + + "." + + compositionPropertyName + + "." + + attachmentPart; + } else { + String intermediatePath = findPathToEntity(targetEntity, ownerEntityQN, new HashSet<>()); + if (intermediatePath != null) { + return parentEntity.getQualifiedName() + + "." + + compositionPropertyName + + "." + + intermediatePath + + "." + + attachmentPart; + } + } } } catch (Exception e) { logger.warn(SDMUtils.getErrorMessage("FETCH_ATTACHMENT_COMPOSITION_ERROR"), e.getMessage()); @@ -304,6 +320,27 @@ private static String buildActualPath( return null; } + private static String findPathToEntity( + CdsEntity fromEntity, String toEntityQN, Set visited) { + if (visited.contains(fromEntity.getQualifiedName())) return null; + visited.add(fromEntity.getQualifiedName()); + List comps = fromEntity.compositions().collect(java.util.stream.Collectors.toList()); + for (Object comp : comps) { + com.sap.cds.reflect.CdsElement element = (com.sap.cds.reflect.CdsElement) comp; + if (!element.getType().isAssociation()) continue; + CdsAssociationType assocType = (CdsAssociationType) element.getType(); + CdsEntity target = assocType.getTarget(); + if (toEntityQN.equals(target.getQualifiedName())) { + return element.getName(); + } + String subPath = findPathToEntity(target, toEntityQN, visited); + if (subPath != null) { + return element.getName() + "." + subPath; + } + } + return null; + } + private static List> findNestedAttachments( Map entity, String attachmentKey, String parentKey, String currentParentKey) { List> result = new ArrayList<>(); diff --git a/sdm/src/main/java/com/sap/cds/sdm/handler/common/SDMApplicationHandlerHelper.java b/sdm/src/main/java/com/sap/cds/sdm/handler/common/SDMApplicationHandlerHelper.java index 5d8d692d3..a4b251567 100644 --- a/sdm/src/main/java/com/sap/cds/sdm/handler/common/SDMApplicationHandlerHelper.java +++ b/sdm/src/main/java/com/sap/cds/sdm/handler/common/SDMApplicationHandlerHelper.java @@ -13,14 +13,31 @@ public final class SDMApplicationHandlerHelper { private static final String ANNOTATION_IS_MEDIA_DATA = "_is_media_data"; /** - * Checks if the entity is a media entity. A media entity is an entity that is annotated with the - * annotation "_is_media_data". + * Checks if the entity is a media entity. A media entity is one that carries the "_is_media_data" + * annotation (set by the CAP attachments plugin on DB-backed attachment entities), or — as a + * fallback for service-layer-only draft entities whose annotation may not be propagated — one + * that has both the SDM-specific "objectId" element and the "content" element that is + * characteristic of the sap.attachments.Attachments aspect. * * @param baseEntity The entity to check * @return true if the entity is a media entity, false otherwise */ public static boolean isMediaEntity(CdsStructuredType baseEntity) { boolean isMedia = baseEntity.getAnnotationValue(ANNOTATION_IS_MEDIA_DATA, false); + if (!isMedia) { + // Fallback for service-layer-only entities (e.g. SupplierBidTermValuesServiceEntity + // attachments) whose inline Composition of Attachments does not receive the + // _is_media_data annotation at runtime. Presence of both "objectId" (SDM-specific + // extension) and "content" (core MediaData field) is a reliable structural signal. + isMedia = + baseEntity.findElement("objectId").isPresent() + && baseEntity.findElement("content").isPresent(); + if (isMedia) { + logger.debug( + "Entity {} identified as media entity via structural fallback (objectId + content)", + baseEntity.getQualifiedName()); + } + } logger.debug("Entity {} isMediaEntity: {}", baseEntity.getQualifiedName(), isMedia); return isMedia; } diff --git a/sdm/src/main/java/com/sap/cds/sdm/utilities/SDMUtils.java b/sdm/src/main/java/com/sap/cds/sdm/utilities/SDMUtils.java index bbe5b9c15..89572bd5b 100644 --- a/sdm/src/main/java/com/sap/cds/sdm/utilities/SDMUtils.java +++ b/sdm/src/main/java/com/sap/cds/sdm/utilities/SDMUtils.java @@ -398,9 +398,9 @@ public static Map getSecondaryPropertiesWithInvalidDefinition( title = titleAnnotation.get().getValue().toString(); } else { title = element.getName(); /* - * This is in case the user has not specified a title for the column in the cds - * file (which is optional) - */ + * This is in case the user has not specified a title for the column in the cds + * file (which is optional) + */ } invalidProperties.put(key, title); } @@ -570,7 +570,19 @@ private static void retrieveAnnotations(CdsElement cdsElement, AttachmentInfo at } private static List getKeyElementNames(CdsEntity entity) { - return entity.elements().filter(CdsElement::isKey).map(CdsElement::getName).toList(); + return entity + .elements() + .filter(CdsElement::isKey) + .map( + e -> { + String name = e.getName(); + // _drafts entity key columns have an "up__" prefix (e.g. up__bidUUID, + // up__bookItem_ID). + // The CQN where clause uses the bare field names (bidUUID, bookItem_ID). + // Strip the prefix so the contains-check in fetchUPIDFromCQN matches correctly. + return name.startsWith("up__") ? name.substring(4) : name; + }) + .toList(); } /** @@ -586,26 +598,37 @@ public static String fetchUPIDFromCQN(CqnSelect select, CdsEntity parentEntity) String upID = null; ObjectMapper mapper = new ObjectMapper(); JsonNode root = mapper.readTree(select.toString()); + logger.debug( + "fetchUPIDFromCQN - CQN from.ref: {}", root.path("SELECT").path("from").path("ref")); JsonNode refArray = root.path("SELECT").path("from").path("ref"); - JsonNode secondLast = refArray.get(refArray.size() - 2); - JsonNode whereArray; - if (secondLast != null) { - whereArray = secondLast.path("where"); - } else { - whereArray = refArray; + // Get the actual key field names from the parent entity + List keyElementNames = getKeyElementNames(parentEntity); + + // Scan ref nodes from right to left (excluding the last, which is the attachment entity + // itself) to find the first node that has a where clause containing a matching key. + // This handles deep navigation paths like: + // SourcingEvents(id=...) / items(id=...) / terms(id=...) / initialSlice / itemValue / + // attachments + // where intermediate nodes (initialSlice, itemValue) have no where clause. + JsonNode whereArray = null; + for (int r = refArray.size() - 2; r >= 0; r--) { + JsonNode refNode = refArray.get(r); + if (refNode == null) continue; + JsonNode candidate = refNode.path("where"); + if (candidate != null && !candidate.isMissingNode() && candidate.size() > 0) { + whereArray = candidate; + break; + } } - // If where condition is not present or empty, return null (valid scenario for - // select without - // filter) - if (whereArray == null || whereArray.isMissingNode() || whereArray.size() == 0) { + // If no ref node with a where clause was found, return null (valid scenario for + // select without filter) + if (whereArray == null) { + logger.debug("END: fetchUPIDFromCQN - upID: null (no where clause found in ref chain)"); return null; } - // Get the actual key field names from the parent entity - List keyElementNames = getKeyElementNames(parentEntity); - for (int i = 0; i < whereArray.size(); i++) { JsonNode node = whereArray.get(i);