Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) {
configurer.eventHandler(
new DraftPatchAttachmentsHandler(persistenceService, eventFactory, defaultMaxSize));
configurer.eventHandler(new DraftCancelAttachmentsHandler(attachmentsReader, deleteEvent));
configurer.eventHandler(new DraftActiveAttachmentsHandler(storage));
configurer.eventHandler(new DraftActiveAttachmentsHandler(storage, persistenceService));
} else {
logger.debug("No draft service is available. Draft event handlers will not be registered.");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import com.sap.cds.CdsData;
import com.sap.cds.feature.attachments.handler.applicationservice.helper.ExtendedErrorStatuses;
import com.sap.cds.feature.attachments.handler.applicationservice.helper.ItemCountValidator;
import com.sap.cds.feature.attachments.handler.applicationservice.helper.ModifyApplicationHandlerHelper;
import com.sap.cds.feature.attachments.handler.applicationservice.helper.ReadonlyDataContextEnhancer;
import com.sap.cds.feature.attachments.handler.applicationservice.helper.ThreadDataStorageReader;
Expand Down Expand Up @@ -70,6 +71,11 @@ void processBefore(CdsCreateEventContext context, List<CdsData> data) {
ModifyApplicationHandlerHelper.handleAttachmentForEntities(
context.getTarget(), data, new ArrayList<>(), eventFactory, context, defaultMaxSize);
}
// Skip item count validation during draft activate – DraftActiveAttachmentsHandler
// performs the authoritative check against the full DB attachment count before activation.
if (!storageReader.get()) {
ItemCountValidator.validate(context.getTarget(), data, context, false);
}
}

@On(event = {CqnService.EVENT_CREATE, CqnService.EVENT_UPDATE, DraftService.EVENT_DRAFT_PATCH})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import com.sap.cds.CdsData;
import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments;
import com.sap.cds.feature.attachments.handler.applicationservice.helper.ItemCountValidator;
import com.sap.cds.feature.attachments.handler.applicationservice.helper.ModifyApplicationHandlerHelper;
import com.sap.cds.feature.attachments.handler.applicationservice.helper.ReadonlyDataContextEnhancer;
import com.sap.cds.feature.attachments.handler.applicationservice.helper.ThreadDataStorageReader;
Expand Down Expand Up @@ -94,6 +95,12 @@ void processBefore(CdsUpdateEventContext context, List<CdsData> data) {
if (!associationsAreUnchanged) {
deleteRemovedAttachments(attachments, data, target, context.getUserInfo());
}

// Skip item count validation during draft activate – DraftActiveAttachmentsHandler
// performs the authoritative check against the full DB attachment count before activation.
if (!storageReader.get()) {
ItemCountValidator.validate(target, data, context, false);
}
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
/*
* © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors.
*/
package com.sap.cds.feature.attachments.handler.applicationservice.helper;

import com.sap.cds.CdsData;
import com.sap.cds.reflect.CdsAnnotation;
import com.sap.cds.reflect.CdsElement;
import com.sap.cds.reflect.CdsEntity;
import com.sap.cds.services.EventContext;
import com.sap.cds.services.messages.Message;
import com.sap.cds.services.messages.Messages;
import java.util.List;
import java.util.Optional;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
* Validates {@code @Validation.MinItems} and {@code @Validation.MaxItems} constraints on attachment
* compositions.
*
* <p>The annotation value may be an integer literal, a reference to a property on the parent entity
* (resolved at runtime from the event data), or any other raw value whose {@code toString()}
* produces a parseable integer.
*
* <p>During draft operations the validator emits a <em>warning</em> message. During active-entity
* operations (create/update/save) it emits an <em>error</em> message and throws immediately via
* {@link Messages#throwIfError()}.
*
* <p>Error messages follow the Fiori elements i18n-override pattern: the base message key is {@code
* AttachmentMinItems} / {@code AttachmentMaxItems}. To override the message for a specific
* composition property the key {@code AttachmentMinItems_<EntityName>_<PropertyName>} or {@code
* AttachmentMaxItems_<EntityName>_<PropertyName>} is tried first. Since CDS resolves the
* most-specific key it can find, only the specific key is emitted – a missing specific translation
* naturally falls back to the generic one.
*/
public final class ItemCountValidator {

static final String ANNOTATION_MIN_ITEMS = "Validation.MinItems";
static final String ANNOTATION_MAX_ITEMS = "Validation.MaxItems";

static final String MSG_MIN_ITEMS = "AttachmentMinItems";
static final String MSG_MAX_ITEMS = "AttachmentMaxItems";

private static final Logger logger = LoggerFactory.getLogger(ItemCountValidator.class);

private ItemCountValidator() {}

/**
* Validates min/max items constraints for all attachment compositions on {@code entity} using the
* payload {@code data} as source of truth for the attachment count.
*
* @param entity the root {@link CdsEntity} whose compositions are checked
* @param data the request payload (used to count passed-in attachments)
* @param eventContext the current {@link EventContext}
* @param isDraft {@code true} when executing in draft context – issues warnings instead of errors
*/
public static void validate(
CdsEntity entity, List<CdsData> data, EventContext eventContext, boolean isDraft) {
entity
.compositions()
.filter(ItemCountValidator::hasItemCountAnnotation)
.forEach(comp -> validateComposition(comp, entity, data, eventContext, isDraft));
}

// ---------------------------------------------------------------------------
// internals
// ---------------------------------------------------------------------------

/**
* Returns {@code true} if the given composition element carries at least one item-count
* constraint annotation ({@code @Validation.MinItems} or {@code @Validation.MaxItems}).
*
* <p>This method is {@code public} so it can be referenced from handler classes outside this
* package (e.g. {@code DraftActiveAttachmentsHandler}).
*
* @param element the composition element to inspect
* @return {@code true} if an item-count annotation is present
*/
public static boolean hasItemCountAnnotation(CdsElement element) {
return element.findAnnotation(ANNOTATION_MIN_ITEMS).isPresent()
|| element.findAnnotation(ANNOTATION_MAX_ITEMS).isPresent();
}

private static void validateComposition(
CdsElement comp,
CdsEntity parentEntity,
List<CdsData> data,
EventContext eventContext,
boolean isDraft) {

String compName = comp.getName();
long attachmentCount = countInPayload(compName, data);

Messages messages = eventContext.getMessages();

// --- MinItems ---
comp.findAnnotation(ANNOTATION_MIN_ITEMS)
.flatMap(ann -> resolveIntValue(ann, parentEntity, data))
.ifPresent(
minItems -> {
if (attachmentCount < minItems) {
String msgKey = messageKey(MSG_MIN_ITEMS, parentEntity, compName);
logger.debug(
"MinItems violation on {}.{}: count={}, min={}",
parentEntity.getQualifiedName(),
compName,
attachmentCount,
minItems);
Message msg =
isDraft
? messages.warn(msgKey, attachmentCount, minItems)
: messages.error(msgKey, attachmentCount, minItems);
msg.target(compName);
}
});

// --- MaxItems ---
comp.findAnnotation(ANNOTATION_MAX_ITEMS)
.flatMap(ann -> resolveIntValue(ann, parentEntity, data))
.ifPresent(
maxItems -> {
if (attachmentCount > maxItems) {
String msgKey = messageKey(MSG_MAX_ITEMS, parentEntity, compName);
logger.debug(
"MaxItems violation on {}.{}: count={}, max={}",
parentEntity.getQualifiedName(),
compName,
attachmentCount,
maxItems);
Message msg =
isDraft
? messages.warn(msgKey, attachmentCount, maxItems)
: messages.error(msgKey, attachmentCount, maxItems);
msg.target(compName);
}
});

if (!isDraft) {
messages.throwIfError();
}
}

/** Counts the number of items in the named composition across all data entries. */
@SuppressWarnings("unchecked")
private static long countInPayload(String compName, List<CdsData> data) {
long count = 0;
for (CdsData entry : data) {
Object compValue = entry.get(compName);
if (compValue instanceof List<?> list) {
count += list.size();
}
}
return count;
}

/**
* Resolves the annotation value to a long. Handles:
*
* <ul>
* <li>Integer literal (e.g. {@code 20})
* <li>Property reference – a string matching a numeric property name on the parent entity data
* (e.g. {@code stock})
* <li>Any other raw string whose {@code toString()} parses as a long
* </ul>
*/
static Optional<Long> resolveIntValue(
CdsAnnotation<?> annotation, CdsEntity entity, List<CdsData> data) {
Object raw = annotation.getValue();
if (raw == null) {
return Optional.empty();
}

// Direct numeric value
if (raw instanceof Number number) {
return Optional.of(number.longValue());
}

String strValue = raw.toString().trim();

// Try parsing directly as a number first
try {
return Optional.of(Long.parseLong(strValue));
} catch (NumberFormatException ignored) {
// fall through
}

// Try resolving as a property reference on the first data item
if (!data.isEmpty() && !strValue.isEmpty()) {
Object propValue = data.get(0).get(strValue);
Copy link
Contributor

Choose a reason for hiding this comment

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

Logic Error: Property-reference resolution only looks at the first data item

When multiple root rows are passed in data, the property reference (e.g. stock) is always read from data.get(0). If each root row can have a different stock value the constraint for rows 1..N is evaluated using the wrong threshold. The count across all rows is summed by countInPayload, but the limit only comes from row 0.

Consider resolving the property reference per root row and validating each row's composition count against that row's limit, or document that only single-root payloads are supported.


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

if (propValue instanceof Number number) {
return Optional.of(number.longValue());
}
if (propValue != null) {
try {
return Optional.of(Long.parseLong(propValue.toString().trim()));
} catch (NumberFormatException e) {
logger.warn(
"Cannot resolve @{} value '{}' as integer from property '{}'",
annotation.getName(),
raw,
strValue);
}
}
}

logger.warn(
"Cannot resolve @{} annotation value '{}' to an integer – skipping validation",
annotation.getName(),
raw);
return Optional.empty();
}

/**
* Builds an i18n message key following the Fiori elements override pattern. The specific key
* {@code <baseKey>_<SimpleName>_<propertyName>} is always used; apps without a specific
* translation entry will receive the message text from the generic {@code <baseKey>} fallback.
*/
static String messageKey(String baseKey, CdsEntity entity, String propertyName) {
String simpleName = simpleEntityName(entity.getQualifiedName());
return baseKey + "_" + simpleName + "_" + propertyName;
}

private static String simpleEntityName(String qualifiedName) {
int dot = qualifiedName.lastIndexOf('.');
return dot >= 0 ? qualifiedName.substring(dot + 1) : qualifiedName;
}
}
Original file line number Diff line number Diff line change
@@ -1,29 +1,111 @@
/*
* © 2024-2025 SAP SE or an SAP affiliate company and cds-feature-attachments contributors.
* © 2024-2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors.
*/
package com.sap.cds.feature.attachments.handler.draftservice;

import static java.util.Objects.requireNonNull;

import com.sap.cds.CdsData;
import com.sap.cds.feature.attachments.handler.applicationservice.helper.ItemCountValidator;
import com.sap.cds.feature.attachments.handler.applicationservice.helper.ThreadDataStorageSetter;
import com.sap.cds.ql.CQL;
import com.sap.cds.ql.Select;
import com.sap.cds.reflect.CdsElement;
import com.sap.cds.reflect.CdsEntity;
import com.sap.cds.services.draft.DraftSaveEventContext;
import com.sap.cds.services.draft.DraftService;
import com.sap.cds.services.handler.EventHandler;
import com.sap.cds.services.handler.annotations.Before;
import com.sap.cds.services.handler.annotations.HandlerOrder;
import com.sap.cds.services.handler.annotations.On;
import com.sap.cds.services.handler.annotations.ServiceName;
import com.sap.cds.services.persistence.PersistenceService;
import java.util.ArrayList;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

@ServiceName(value = "*", type = DraftService.class)
public class DraftActiveAttachmentsHandler implements EventHandler {

private static final Logger logger = LoggerFactory.getLogger(DraftActiveAttachmentsHandler.class);

private final ThreadDataStorageSetter threadLocalSetter;
private final PersistenceService persistence;

public DraftActiveAttachmentsHandler(ThreadDataStorageSetter threadLocalSetter) {
public DraftActiveAttachmentsHandler(
ThreadDataStorageSetter threadLocalSetter, PersistenceService persistence) {
this.threadLocalSetter =
requireNonNull(threadLocalSetter, "threadLocalSetter must not be null");
this.persistence = requireNonNull(persistence, "persistence must not be null");
}

/**
* Before draft save: validate min/max items as errors. Draft save = activating the draft, so an
* invalid state is no longer tolerated and must result in an error.
*/
@Before
@HandlerOrder(HandlerOrder.LATE)
void validateItemCountBeforeSave(DraftSaveEventContext context) {
CdsEntity entity = context.getTarget();

boolean hasItemCountAnnotations =
entity.compositions().anyMatch(ItemCountValidator::hasItemCountAnnotation);
if (!hasItemCountAnnotations) {
return;
}

logger.debug(
"Validating item count before draft save for entity {}", entity.getQualifiedName());

CdsEntity draftEntity = DraftUtils.getDraftEntity(entity);
List<CdsData> syntheticData = readDraftCompositionCounts(draftEntity, context, entity);
ItemCountValidator.validate(entity, syntheticData, context, false);
}

@On
void processDraftSave(DraftSaveEventContext context) {
threadLocalSetter.set(true, context::proceed);
}

/**
* Reads the draft composition data for all annotated compositions and builds a synthetic data
* list suitable for {@link ItemCountValidator#validate}.
*
* <p>A SELECT with expand for each annotated composition is executed against the draft table. The
* result is a single root {@link CdsData} entry whose composition arrays contain all found child
* rows – this allows {@code ItemCountValidator} to count them correctly.
*/
private List<CdsData> readDraftCompositionCounts(
CdsEntity draftEntity, DraftSaveEventContext context, CdsEntity activeEntity) {
List<CdsElement> annotatedComps =
activeEntity.compositions().filter(ItemCountValidator::hasItemCountAnnotation).toList();

var expandColumns =
annotatedComps.stream().map(comp -> CQL.to(comp.getName()).expand()).toList();

// context.getCqn() is the CqnSelect that identifies the draft root entity (with keys/where).
// We re-build a SELECT on the draft entity adding the expand columns for compositions.
var baseCqn = context.getCqn();
Select<?> select = Select.from(draftEntity).columns(expandColumns);
baseCqn.where().ifPresent(select::where);

var result = persistence.run(select);
List<CdsData> rows = result.listOf(CdsData.class);

// Aggregate: collect all composition items across root rows into one synthetic entry.
// In practice draft save targets a single root entity, but we sum across all for safety.
CdsData aggregated = CdsData.create();
for (CdsElement comp : annotatedComps) {
List<CdsData> allItems = new ArrayList<>();
for (CdsData row : rows) {
Object items = row.get(comp.getName());
if (items instanceof List<?> list) {
list.forEach(item -> allItems.add(CdsData.create()));
}
}
aggregated.put(comp.getName(), allItems);
}
return List.of(aggregated);
}
}
Loading
Loading