Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/actions/newrelease/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion .github/workflows/main-build-and-deploy-oss.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ on:
types: [ "released" ]

permissions:
contents: read
contents: write

jobs:

Expand Down
1 change: 1 addition & 0 deletions cap-notebook/version.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
1.10.2
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
</developers>

<properties>
<revision>1.10.1-SNAPSHOT</revision>
<revision>1.10.3.1-SNAPSHOT</revision>
<java.version>21</java.version>
<maven.compiler.source>${java.version}</maven.compiler.source>
<maven.compiler.target>${java.version}</maven.compiler.target>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ private static void processAttachmentPaths(
List<String> 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
Expand Down Expand Up @@ -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;
}
Expand All @@ -284,26 +285,62 @@ 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());
}
return null;
}

private static String findPathToEntity(
CdsEntity fromEntity, String toEntityQN, Set<String> 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<Map<String, Object>> findNestedAttachments(
Map<String, Object> entity, String attachmentKey, String parentKey, String currentParentKey) {
List<Map<String, Object>> result = new ArrayList<>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 <code>true</code> if the entity is a media entity, <code>false</code> 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;
}
Expand Down
57 changes: 40 additions & 17 deletions sdm/src/main/java/com/sap/cds/sdm/utilities/SDMUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -398,9 +398,9 @@ public static Map<String, String> 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);
}
Expand Down Expand Up @@ -570,7 +570,19 @@ private static void retrieveAnnotations(CdsElement cdsElement, AttachmentInfo at
}

private static List<String> 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();
}

/**
Expand All @@ -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<String> 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<String> keyElementNames = getKeyElementNames(parentEntity);

for (int i = 0; i < whereArray.size(); i++) {
JsonNode node = whereArray.get(i);

Expand Down
Loading