Skip to content

Support inline single attachments via Attachment type#768

Open
busehalis-sap wants to merge 5 commits intomainfrom
feature/support-single-attachments
Open

Support inline single attachments via Attachment type#768
busehalis-sap wants to merge 5 commits intomainfrom
feature/support-single-attachments

Conversation

@busehalis-sap
Copy link
Copy Markdown
Contributor

What

Adds support for inline (single) attachments using the Attachment type directly on an entity field (e.g. profilePicture: Attachment), in addition to the existing composition-based Composition of many Attachments pattern.

Key Changes

  • Inline attachment detection via @_is_media_data annotation at element level
  • Prefix-based extraction to normalize flattened DB columns (e.g. coverImage_contentIdcontentId)
  • All CRUD + draft handlers extended for inline path (read, create, update, delete, patch, cancel)
  • Malware scan support for inline attachments
  • Bookshop sample updated with inline attachment example
  • Comprehensive unit tests for all new paths

How it works

When an entity has field: Attachment, CDS flattens the Attachment fields into the parent table with a prefix (e.g. coverImage_content, coverImage_contentId). The handlers detect this via the @_is_media_data annotation at element level, extract the prefixed data into normalized Attachments maps, and inject parent entity keys for downstream matching.

@hyperspace-insights
Copy link
Copy Markdown
Contributor

Summary

The following content is AI-generated and provides a summary of the pull request:


Support Inline Single Attachments via Attachment Type

New Feature: Adds support for inline (single) attachments using the Attachment type directly on an entity field (e.g., profileIcon: Attachment), complementing the existing Composition of many Attachments pattern.

Changes

When an entity declares a field as field: Attachment, CDS flattens the Attachment fields into the parent table with a prefix (e.g., coverImage_content, coverImage_contentId). The handlers now detect this via the @_is_media_data annotation at the element level, extract the prefixed data into normalized Attachments maps, and inject parent entity keys for downstream matching.

CDS Model:

  • attachments.cds: Introduces a new type Attachment @(_is_media_data) and makes MediaData extend it, enabling inline usage.
  • attachments-annotations.cds: Annotates Attachment.content with a static Core.MediaType to expose it as Edm.Stream.

Handler Infrastructure:

  • ApplicationHandlerHelper.java: New utility methods for inline attachment detection: getInlineAttachmentFieldNames, getInlineAttachmentPrefix, extractInlineAttachment, isInlineAttachmentContentField, hasInlineAttachmentElements, isDirectMediaEntity. Updated MEDIA_CONTENT_FILTER and condenseAttachments to support both inline and composition-based patterns.
  • BeforeReadItemsModifier.java: Extended to add prefixed contentId, status, and scannedAt fields to SELECT queries for inline attachments.
  • ReadAttachmentsHandler.java: Detects inline prefix during read/after-read processing and uses it for status transitions and malware scan triggering.
  • UpdateAttachmentsHandler.java: associationsAreUnchanged now also checks inline prefixed fields.
  • ReadonlyDataContextEnhancer.java: Handles preserve/restore of readonly fields using prefixed keys for inline attachments.

Modify Events:

  • CreateAttachmentEvent.java: Detects inline prefix, reads mimeType/fileName from prefixed fields, and writes contentId/status/scannedAt back using prefixed keys.
  • MarkAsDeletedAttachmentEvent.java: Clears all prefixed fields (including mimeType, fileName) on deletion for inline attachments.

Draft Handlers:

  • DraftPatchAttachmentsHandler.java: Extracts inline attachment data from flattened DB results and injects parent keys for event matching.
  • DraftCancelAttachmentsHandler.java: contentIdFilter and deepSearchForAttachments updated to detect inline attachment fields.

Malware Scanning:

  • AsyncMalwareScanExecutor, AttachmentMalwareScanner, EndTransactionMalwareScanProvider, EndTransactionMalwareScanRunner: Added Optional<String> inlinePrefix parameter throughout the malware scan pipeline.
  • DefaultAttachmentMalwareScanner.java: All read/update queries now use resolved (prefixed) column names for inline attachments.
  • DefaultAttachmentsServiceHandler.java: Detects inline prefix post-create and passes it to the malware scan provider.

Other:

  • AssociationCascader.java: Recognizes entities with inline attachment elements and adds them to the traversal path.
  • AttachmentsReader.java: Adds inline attachment columns (prefix_contentId, prefix_status) directly to SELECT queries.
  • MediaTypeResolver.java, AttachmentValidationHelper.java: Use findElement (safe) instead of getElement and isDirectMediaEntity for composition-based checks.
  • Registration.java: Updated EndTransactionMalwareScanRunner instantiation to pass Optional.empty() for inlinePrefix.

Bookshop Sample:

  • attachments.cds, fiori-service.cds: Added profileIcon: Attachment inline attachment to the Books entity with UI facet.
  • pom.xml: Updated cds-feature-attachments version to 1.4.0-SNAPSHOT.

Tests:

  • New and extended unit tests covering all new inline paths: read, create, update, delete, patch, cancel, malware scanning, readonly field preservation, and BeforeReadItemsModifier behavior.
  • New ReadonlyDataContextEnhancerTest.java covering both composition-based and inline preserve/restore scenarios.

  • 🔄 Regenerate and Update Summary
  • ✏️ Insert as PR Description (deletes this comment)
  • 🗑️ Delete comment
PR Bot Information

Version: 1.19.3 | 📖 Documentation | 🚨 Create Incident | 💬 Feedback

  • Output Template: Default Template
  • LLM: anthropic--claude-4.6-sonnet
  • Summary Prompt: Default Prompt
  • Event Trigger: pull_request.opened
  • Correlation ID: bbb28610-2bff-11f1-8fd9-1dd0dd2b50a5

💌 Have ideas or want to contribute? Create an issue and share your thoughts with us!
📑 Check out the documentation for more information.
📬 Subscribe to the Hyperspace PR Bot DL to get the latest announcements and pilot features!

Made with ❤️ by Hyperspace.

Copy link
Copy Markdown
Contributor

@hyperspace-insights hyperspace-insights bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR introduces inline single-attachment support via the Attachment type, which is a meaningful extension of the existing composition-based approach. However, there are several substantive correctness issues that need attention before merging: a recurring "first prefix wins" bug across CreateAttachmentEvent, MarkAsDeletedAttachmentEvent, and DefaultAttachmentsServiceHandler that will produce wrong behavior for entities with more than one inline attachment field; an inverted/dead branch in ModifyApplicationHandlerHelper for contentId resolution; missing root-entity key columns in AttachmentsReader when both inline and expand columns are present; and a conflation in ReadAttachmentsHandler.getAttachmentAssociations that incorrectly treats inline-only root entities as direct media association roots. These should be resolved before the feature is considered production-ready.

PR Bot Information

Version: 1.19.3 | 📖 Documentation | 🚨 Create Incident | 💬 Feedback

  • Event Trigger: pull_request.opened
  • Correlation ID: bbb28610-2bff-11f1-8fd9-1dd0dd2b50a5
  • LLM: anthropic--claude-4.6-sonnet

Comment on lines +88 to +93
private static Optional<String> detectInlinePrefix(Path path) {
List<String> prefixes =
ApplicationHandlerHelper.getInlineAttachmentFieldNames(path.target().entity());
if (!prefixes.isEmpty()
&& !path.target().entity().getAnnotationValue("_is_media_data", false)) {
return Optional.of(prefixes.get(0));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Logic Error: detectInlinePrefix always picks the first inline prefix, silently ignoring entities with multiple inline attachment fields.

When an entity has more than one inline Attachment field (e.g. coverImage: Attachment and thumbnail: Attachment), prefixes.get(0) always returns the first one. The CdsDataProcessor converter is already called once per matched content element, so path already points to the specific element being processed. The prefix should be derived from element.getName() (available inside processEvent via the calling path) or, better, passed in from the converter context rather than re-detecting from the entity's full prefix list.

Consider replacing detectInlinePrefix(path) with ApplicationHandlerHelper.getInlineAttachmentPrefix(path.target().entity(), element.getName()), using the actual element name from the converter callback—similar to how ReadAttachmentsHandler.processAfter correctly does it at line 124.


Please provide feedback on the review comment by checking the appropriate box:

  • 🌟 Awesome comment, a human might have missed that.
  • ✅ Helpful comment
  • 🤷 Neutral
  • ❌ This comment is not helpful

Comment on lines +78 to +83
private static Optional<String> detectInlinePrefix(Path path) {
List<String> prefixes =
ApplicationHandlerHelper.getInlineAttachmentFieldNames(path.target().entity());
if (!prefixes.isEmpty()
&& !path.target().entity().getAnnotationValue("_is_media_data", false)) {
return Optional.of(prefixes.get(0));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Logic Error: Same multi-inline-field bug as in CreateAttachmentEventdetectInlinePrefix picks prefixes.get(0) regardless of which field is actually being processed.

If an entity has two inline attachment fields, the delete handler will always clear the first prefix's fields, leaving the second prefix's fields with stale data, and potentially clearing the wrong prefix's fields entirely.

Should derive the prefix from the element name in context, not unconditionally take the first element of the list.


Please provide feedback on the review comment by checking the appropriate box:

  • 🌟 Awesome comment, a human might have missed that.
  • ✅ Helpful comment
  • 🤷 Neutral
  • ❌ This comment is not helpful

Comment on lines +79 to +84
private static Optional<String> detectInlinePrefix(CdsEntity entity) {
List<String> prefixes = ApplicationHandlerHelper.getInlineAttachmentFieldNames(entity);
if (!prefixes.isEmpty() && !entity.getAnnotationValue("_is_media_data", false)) {
return Optional.of(prefixes.get(0));
}
return Optional.empty();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Logic Error: Same first-prefix-wins bug: detectInlinePrefix takes prefixes.get(0) unconditionally.

afterCreateAttachment is called with an AttachmentCreateEventContext that already carries the attachmentEntity. If the entity has multiple inline attachment fields the wrong (or first) prefix will be forwarded to the malware scanner, causing the scanner to update the wrong flattened column after the transaction.

Consider passing the prefix used during the create operation directly via the event context instead of re-deriving it here.


Please provide feedback on the review comment by checking the appropriate box:

  • 🌟 Awesome comment, a human might have missed that.
  • ✅ Helpful comment
  • 🤷 Neutral
  • ❌ This comment is not helpful

Comment on lines +92 to +107
String contentId;
Optional<String> inlinePrefix =
ApplicationHandlerHelper.getInlineAttachmentPrefix(
path.target().entity(), Attachments.CONTENT_ID);
if (inlinePrefix.isEmpty()) {
// Try to detect prefix from any inline attachment field present in data
contentId =
resolveInlineOrDirectField(
path.target().values(), path.target().entity(), Attachments.CONTENT_ID);
} else {
contentId =
(String) path.target().values().get(inlinePrefix.get() + "_" + Attachments.CONTENT_ID);
}
if (contentId == null) {
contentId = (String) path.target().values().get(Attachments.CONTENT_ID);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Logic Error: The contentId resolution logic is inverted and has a dead branch.

getInlineAttachmentPrefix(entity, Attachments.CONTENT_ID) looks for a prefix whose value starts with "contentId_", but the field name is "contentId" not a prefixed element name. This call will almost always return Optional.empty(), causing the code to always fall into the if (inlinePrefix.isEmpty()) branch. The subsequent if (contentId == null) fallback then re-reads the direct field — this is the exact same field already tried in resolveInlineOrDirectField, making the fallback redundant.

The intent appears to be: resolve contentId from the prefixed field when the entity is an inline-attachment host. Consider instead using getInlineAttachmentPrefix(entity, element.getName()) at the point where the element name is available, or simply call resolveInlineOrDirectField directly and remove the dead else branch.

Suggested change
String contentId;
Optional<String> inlinePrefix =
ApplicationHandlerHelper.getInlineAttachmentPrefix(
path.target().entity(), Attachments.CONTENT_ID);
if (inlinePrefix.isEmpty()) {
// Try to detect prefix from any inline attachment field present in data
contentId =
resolveInlineOrDirectField(
path.target().values(), path.target().entity(), Attachments.CONTENT_ID);
} else {
contentId =
(String) path.target().values().get(inlinePrefix.get() + "_" + Attachments.CONTENT_ID);
}
if (contentId == null) {
contentId = (String) path.target().values().get(Attachments.CONTENT_ID);
}
// Resolve contentId: try inline prefixed fields first, then fall back to direct field
String contentId =
resolveInlineOrDirectField(
path.target().values(), path.target().entity(), Attachments.CONTENT_ID);

Double-check suggestion before committing. Edit this comment for amendments.


Please provide feedback on the review comment by checking the appropriate box:

  • 🌟 Awesome comment, a human might have missed that.
  • ✅ Helpful comment
  • 🤷 Neutral
  • ❌ This comment is not helpful

Comment on lines +113 to +130
public static List<String> getInlineAttachmentFieldNames(CdsStructuredType entity) {
List<String> fieldNames = new ArrayList<>();
var elements = entity.elements();
if (elements == null) return fieldNames;
String contentSuffix = "_content";
elements
.filter(e -> e.getName().endsWith(contentSuffix))
.filter(e -> e.getAnnotationValue(ANNOTATION_IS_MEDIA_DATA, false))
.filter(e -> e.findAnnotation(ANNOTATION_CORE_MEDIA_TYPE).isPresent())
.forEach(
e -> {
String prefix =
e.getName().substring(0, e.getName().length() - contentSuffix.length());
if (!prefix.isEmpty() && !fieldNames.contains(prefix)) {
fieldNames.add(prefix);
}
});
return fieldNames;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Performance: getInlineAttachmentFieldNames uses List.contains in its inner forEach, giving O(n²) behaviour when an entity has many inline attachment fields.

Although practically rare today, the list is built by iterating all elements and then checking !fieldNames.contains(prefix) for every element. Should use a LinkedHashSet internally (to preserve insertion order and deduplicate in O(1)) and convert to List before returning.

Suggested change
public static List<String> getInlineAttachmentFieldNames(CdsStructuredType entity) {
List<String> fieldNames = new ArrayList<>();
var elements = entity.elements();
if (elements == null) return fieldNames;
String contentSuffix = "_content";
elements
.filter(e -> e.getName().endsWith(contentSuffix))
.filter(e -> e.getAnnotationValue(ANNOTATION_IS_MEDIA_DATA, false))
.filter(e -> e.findAnnotation(ANNOTATION_CORE_MEDIA_TYPE).isPresent())
.forEach(
e -> {
String prefix =
e.getName().substring(0, e.getName().length() - contentSuffix.length());
if (!prefix.isEmpty() && !fieldNames.contains(prefix)) {
fieldNames.add(prefix);
}
});
return fieldNames;
public static List<String> getInlineAttachmentFieldNames(CdsStructuredType entity) {
var elements = entity.elements();
if (elements == null) return List.of();
String contentSuffix = "_content";
java.util.LinkedHashSet<String> fieldNames = new java.util.LinkedHashSet<>();
elements
.filter(e -> e.getName().endsWith(contentSuffix))
.filter(e -> e.getAnnotationValue(ANNOTATION_IS_MEDIA_DATA, false))
.filter(e -> e.findAnnotation(ANNOTATION_CORE_MEDIA_TYPE).isPresent())
.forEach(
e -> {
String prefix =
e.getName().substring(0, e.getName().length() - contentSuffix.length());
if (!prefix.isEmpty()) {
fieldNames.add(prefix);
}
});
return new ArrayList<>(fieldNames);
}

Double-check suggestion before committing. Edit this comment for amendments.


Please provide feedback on the review comment by checking the appropriate box:

  • 🌟 Awesome comment, a human might have missed that.
  • ✅ Helpful comment
  • 🤷 Neutral
  • ❌ This comment is not helpful

Comment on lines +85 to +91
// Also check for inline attachment type fields on the entity itself
if (ApplicationHandlerHelper.hasInlineAttachmentElements(entity)) {
var identifier = new AssociationIdentifier(associationName, entity.getQualifiedName());
var inlinePath = new LinkedList<>(firstList);
inlinePath.addLast(identifier);
internalResultList.add(inlinePath);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Logic Error: The inline path added to internalResultList duplicates the identifier that was already added to firstList for the composition-based path.

When hasInlineAttachmentElements is true and there are also composition children, firstList may already contain parent identifiers accumulated from outer recursive calls. Cloning firstList into inlinePath and then adding the current identifier reuses that accumulated state correctly — but if the root entity itself has both inline attachments and composition children, the inline identifier gets added to internalResultList here (line 90), but the composition children also add their full paths in the loop below, potentially causing the root entity's inline path to appear multiple times in the final NodeTree if findEntityPath is called with the root entity on subsequent recursive calls.

This should be verified against the AssociationCascaderTest.rootEntityWithInlineAttachmentDoesNotAddExtraTreeChild test to ensure NodeTree.addPath deduplicates correctly; otherwise, readAttachments may issue redundant DB queries.


Please provide feedback on the review comment by checking the appropriate box:

  • 🌟 Awesome comment, a human might have missed that.
  • ✅ Helpful comment
  • 🤷 Neutral
  • ❌ This comment is not helpful

Comment on lines +59 to +62
if (!expandList.isEmpty() || !inlineColumns.isEmpty()) {
List<CqnSelectListItem> allItems = new ArrayList<>(inlineColumns);
allItems.addAll(expandList);
select = Select.from(statement.ref()).columns(allItems);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: When an entity has both composition-based attachments (non-empty expandList) and inline attachment fields (non-empty inlineColumns), the key columns of the root entity are never added to the select. The query will only return inline columns and expand columns, omitting the root entity's primary keys. This means the returned Attachments records will lack key data needed for matching in getExistingAttachment.

Suggested change
if (!expandList.isEmpty() || !inlineColumns.isEmpty()) {
List<CqnSelectListItem> allItems = new ArrayList<>(inlineColumns);
allItems.addAll(expandList);
select = Select.from(statement.ref()).columns(allItems);
Select<?> select;
if (!expandList.isEmpty() || !inlineColumns.isEmpty()) {
List<CqnSelectListItem> allItems = new ArrayList<>();
allItems.add(CQL.get("*")); // ensure root key columns are always included
allItems.addAll(inlineColumns);
allItems.addAll(expandList);
select = Select.from(statement.ref()).columns(allItems);
} else {
select = Select.from(statement.ref()).columns(StructuredType::_all);
}

Double-check suggestion before committing. Edit this comment for amendments.


Please provide feedback on the review comment by checking the appropriate box:

  • 🌟 Awesome comment, a human might have missed that.
  • ✅ Helpful comment
  • 🤷 Neutral
  • ❌ This comment is not helpful

Comment on lines 154 to 164
List<String> associationNames = new ArrayList<>();
if (ApplicationHandlerHelper.isMediaEntity(entity)) {
if (ApplicationHandlerHelper.isDirectMediaEntity(entity)) {
associationNames.add(associationName);
}

// Also add inline attachment field names (for inline type support)
List<String> inlineFields = ApplicationHandlerHelper.getInlineAttachmentFieldNames(entity);
if (!inlineFields.isEmpty() && !associationNames.contains(associationName)) {
// Use empty string to signify inline fields on the root entity
associationNames.add(associationName);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Logic Error: getAttachmentAssociations now adds associationName (which is "" for the root entity) to associationNames for both direct media entities and entities with inline attachment fields. When the root entity has inline attachments but no @_is_media_data annotation, adding "" to associationNames causes BeforeReadItemsModifier to treat the root as a media association and attempt to add contentId/status/scannedAt fields unconditionally (via the composition-based path), which is incorrect for inline-only roots.

The two cases (direct media vs inline) should be tracked separately — the existing code at lines 155-164 conflates them. Only the inline case should be handled here; the inlinePrefixes list passed to BeforeReadItemsModifier (line 107) already handles inline field injection correctly.


Please provide feedback on the review comment by checking the appropriate box:

  • 🌟 Awesome comment, a human might have missed that.
  • ✅ Helpful comment
  • 🤷 Neutral
  • ❌ This comment is not helpful

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant