Skip to content

Commit 32ac7aa

Browse files
committed
Add @Validation.MinItems / @Validation.MaxItems support for attachment compositions
Introduces `ItemCountValidator` which enforces minimum and maximum attachment counts on composition fields annotated with `@Validation.MinItems` and/or `@Validation.MaxItems`. - Annotation values can be integer literals, parseable strings, or property references resolved at runtime from the entity payload (e.g. `stock`) - Active create/update events: emit an error and throw immediately - Draft patch events: emit a warning only (draft tolerates invalid state) - Draft save (activate): query the draft DB for the real attachment count, then validate as an error — skip the in-payload check in Create/Update handlers during draft activate to avoid double-validation - i18n keys follow the Fiori elements override pattern: `AttachmentMinItems[_EntityName_propertyName]` / `AttachmentMaxItems[_EntityName_propertyName]`
1 parent edbcfc8 commit 32ac7aa

11 files changed

Lines changed: 850 additions & 7 deletions

File tree

cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/configuration/Registration.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) {
145145
configurer.eventHandler(
146146
new DraftPatchAttachmentsHandler(persistenceService, eventFactory, defaultMaxSize));
147147
configurer.eventHandler(new DraftCancelAttachmentsHandler(attachmentsReader, deleteEvent));
148-
configurer.eventHandler(new DraftActiveAttachmentsHandler(storage));
148+
configurer.eventHandler(new DraftActiveAttachmentsHandler(storage, persistenceService));
149149
} else {
150150
logger.debug("No draft service is available. Draft event handlers will not be registered.");
151151
}

cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/CreateAttachmentsHandler.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import com.sap.cds.CdsData;
99
import com.sap.cds.feature.attachments.handler.applicationservice.helper.ExtendedErrorStatuses;
10+
import com.sap.cds.feature.attachments.handler.applicationservice.helper.ItemCountValidator;
1011
import com.sap.cds.feature.attachments.handler.applicationservice.helper.ModifyApplicationHandlerHelper;
1112
import com.sap.cds.feature.attachments.handler.applicationservice.helper.ReadonlyDataContextEnhancer;
1213
import com.sap.cds.feature.attachments.handler.applicationservice.helper.ThreadDataStorageReader;
@@ -70,6 +71,11 @@ void processBefore(CdsCreateEventContext context, List<CdsData> data) {
7071
ModifyApplicationHandlerHelper.handleAttachmentForEntities(
7172
context.getTarget(), data, new ArrayList<>(), eventFactory, context, defaultMaxSize);
7273
}
74+
// Skip item count validation during draft activate – DraftActiveAttachmentsHandler
75+
// performs the authoritative check against the full DB attachment count before activation.
76+
if (!storageReader.get()) {
77+
ItemCountValidator.validate(context.getTarget(), data, context, false);
78+
}
7379
}
7480

7581
@On(event = {CqnService.EVENT_CREATE, CqnService.EVENT_UPDATE, DraftService.EVENT_DRAFT_PATCH})

cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/UpdateAttachmentsHandler.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import com.sap.cds.CdsData;
99
import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments;
10+
import com.sap.cds.feature.attachments.handler.applicationservice.helper.ItemCountValidator;
1011
import com.sap.cds.feature.attachments.handler.applicationservice.helper.ModifyApplicationHandlerHelper;
1112
import com.sap.cds.feature.attachments.handler.applicationservice.helper.ReadonlyDataContextEnhancer;
1213
import com.sap.cds.feature.attachments.handler.applicationservice.helper.ThreadDataStorageReader;
@@ -94,6 +95,12 @@ void processBefore(CdsUpdateEventContext context, List<CdsData> data) {
9495
if (!associationsAreUnchanged) {
9596
deleteRemovedAttachments(attachments, data, target, context.getUserInfo());
9697
}
98+
99+
// Skip item count validation during draft activate – DraftActiveAttachmentsHandler
100+
// performs the authoritative check against the full DB attachment count before activation.
101+
if (!storageReader.get()) {
102+
ItemCountValidator.validate(target, data, context, false);
103+
}
97104
}
98105
}
99106

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
/*
2+
* © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors.
3+
*/
4+
package com.sap.cds.feature.attachments.handler.applicationservice.helper;
5+
6+
import com.sap.cds.CdsData;
7+
import com.sap.cds.reflect.CdsAnnotation;
8+
import com.sap.cds.reflect.CdsElement;
9+
import com.sap.cds.reflect.CdsEntity;
10+
import com.sap.cds.services.EventContext;
11+
import com.sap.cds.services.messages.Message;
12+
import com.sap.cds.services.messages.Messages;
13+
import java.util.List;
14+
import java.util.Optional;
15+
import org.slf4j.Logger;
16+
import org.slf4j.LoggerFactory;
17+
18+
/**
19+
* Validates {@code @Validation.MinItems} and {@code @Validation.MaxItems} constraints on attachment
20+
* compositions.
21+
*
22+
* <p>The annotation value may be an integer literal, a reference to a property on the parent entity
23+
* (resolved at runtime from the event data), or any other raw value whose {@code toString()}
24+
* produces a parseable integer.
25+
*
26+
* <p>During draft operations the validator emits a <em>warning</em> message. During active-entity
27+
* operations (create/update/save) it emits an <em>error</em> message and throws immediately via
28+
* {@link Messages#throwIfError()}.
29+
*
30+
* <p>Error messages follow the Fiori elements i18n-override pattern: the base message key is {@code
31+
* AttachmentMinItems} / {@code AttachmentMaxItems}. To override the message for a specific
32+
* composition property the key {@code AttachmentMinItems_<EntityName>_<PropertyName>} or {@code
33+
* AttachmentMaxItems_<EntityName>_<PropertyName>} is tried first. Since CDS resolves the
34+
* most-specific key it can find, only the specific key is emitted – a missing specific translation
35+
* naturally falls back to the generic one.
36+
*/
37+
public final class ItemCountValidator {
38+
39+
static final String ANNOTATION_MIN_ITEMS = "Validation.MinItems";
40+
static final String ANNOTATION_MAX_ITEMS = "Validation.MaxItems";
41+
42+
static final String MSG_MIN_ITEMS = "AttachmentMinItems";
43+
static final String MSG_MAX_ITEMS = "AttachmentMaxItems";
44+
45+
private static final Logger logger = LoggerFactory.getLogger(ItemCountValidator.class);
46+
47+
private ItemCountValidator() {}
48+
49+
/**
50+
* Validates min/max items constraints for all attachment compositions on {@code entity} using the
51+
* payload {@code data} as source of truth for the attachment count.
52+
*
53+
* @param entity the root {@link CdsEntity} whose compositions are checked
54+
* @param data the request payload (used to count passed-in attachments)
55+
* @param eventContext the current {@link EventContext}
56+
* @param isDraft {@code true} when executing in draft context – issues warnings instead of errors
57+
*/
58+
public static void validate(
59+
CdsEntity entity, List<CdsData> data, EventContext eventContext, boolean isDraft) {
60+
entity
61+
.compositions()
62+
.filter(ItemCountValidator::hasItemCountAnnotation)
63+
.forEach(comp -> validateComposition(comp, entity, data, eventContext, isDraft));
64+
}
65+
66+
// ---------------------------------------------------------------------------
67+
// internals
68+
// ---------------------------------------------------------------------------
69+
70+
/**
71+
* Returns {@code true} if the given composition element carries at least one item-count
72+
* constraint annotation ({@code @Validation.MinItems} or {@code @Validation.MaxItems}).
73+
*
74+
* <p>This method is {@code public} so it can be referenced from handler classes outside this
75+
* package (e.g. {@code DraftActiveAttachmentsHandler}).
76+
*
77+
* @param element the composition element to inspect
78+
* @return {@code true} if an item-count annotation is present
79+
*/
80+
public static boolean hasItemCountAnnotation(CdsElement element) {
81+
return element.findAnnotation(ANNOTATION_MIN_ITEMS).isPresent()
82+
|| element.findAnnotation(ANNOTATION_MAX_ITEMS).isPresent();
83+
}
84+
85+
private static void validateComposition(
86+
CdsElement comp,
87+
CdsEntity parentEntity,
88+
List<CdsData> data,
89+
EventContext eventContext,
90+
boolean isDraft) {
91+
92+
String compName = comp.getName();
93+
long attachmentCount = countInPayload(compName, data);
94+
95+
Messages messages = eventContext.getMessages();
96+
97+
// --- MinItems ---
98+
comp.findAnnotation(ANNOTATION_MIN_ITEMS)
99+
.flatMap(ann -> resolveIntValue(ann, parentEntity, data))
100+
.ifPresent(
101+
minItems -> {
102+
if (attachmentCount < minItems) {
103+
String msgKey = messageKey(MSG_MIN_ITEMS, parentEntity, compName);
104+
logger.debug(
105+
"MinItems violation on {}.{}: count={}, min={}",
106+
parentEntity.getQualifiedName(),
107+
compName,
108+
attachmentCount,
109+
minItems);
110+
Message msg =
111+
isDraft
112+
? messages.warn(msgKey, attachmentCount, minItems)
113+
: messages.error(msgKey, attachmentCount, minItems);
114+
msg.target(compName);
115+
}
116+
});
117+
118+
// --- MaxItems ---
119+
comp.findAnnotation(ANNOTATION_MAX_ITEMS)
120+
.flatMap(ann -> resolveIntValue(ann, parentEntity, data))
121+
.ifPresent(
122+
maxItems -> {
123+
if (attachmentCount > maxItems) {
124+
String msgKey = messageKey(MSG_MAX_ITEMS, parentEntity, compName);
125+
logger.debug(
126+
"MaxItems violation on {}.{}: count={}, max={}",
127+
parentEntity.getQualifiedName(),
128+
compName,
129+
attachmentCount,
130+
maxItems);
131+
Message msg =
132+
isDraft
133+
? messages.warn(msgKey, attachmentCount, maxItems)
134+
: messages.error(msgKey, attachmentCount, maxItems);
135+
msg.target(compName);
136+
}
137+
});
138+
139+
if (!isDraft) {
140+
messages.throwIfError();
141+
}
142+
}
143+
144+
/** Counts the number of items in the named composition across all data entries. */
145+
@SuppressWarnings("unchecked")
146+
private static long countInPayload(String compName, List<CdsData> data) {
147+
long count = 0;
148+
for (CdsData entry : data) {
149+
Object compValue = entry.get(compName);
150+
if (compValue instanceof List<?> list) {
151+
count += list.size();
152+
}
153+
}
154+
return count;
155+
}
156+
157+
/**
158+
* Resolves the annotation value to a long. Handles:
159+
*
160+
* <ul>
161+
* <li>Integer literal (e.g. {@code 20})
162+
* <li>Property reference – a string matching a numeric property name on the parent entity data
163+
* (e.g. {@code stock})
164+
* <li>Any other raw string whose {@code toString()} parses as a long
165+
* </ul>
166+
*/
167+
static Optional<Long> resolveIntValue(
168+
CdsAnnotation<?> annotation, CdsEntity entity, List<CdsData> data) {
169+
Object raw = annotation.getValue();
170+
if (raw == null) {
171+
return Optional.empty();
172+
}
173+
174+
// Direct numeric value
175+
if (raw instanceof Number number) {
176+
return Optional.of(number.longValue());
177+
}
178+
179+
String strValue = raw.toString().trim();
180+
181+
// Try parsing directly as a number first
182+
try {
183+
return Optional.of(Long.parseLong(strValue));
184+
} catch (NumberFormatException ignored) {
185+
// fall through
186+
}
187+
188+
// Try resolving as a property reference on the first data item
189+
if (!data.isEmpty() && !strValue.isEmpty()) {
190+
Object propValue = data.get(0).get(strValue);
191+
if (propValue instanceof Number number) {
192+
return Optional.of(number.longValue());
193+
}
194+
if (propValue != null) {
195+
try {
196+
return Optional.of(Long.parseLong(propValue.toString().trim()));
197+
} catch (NumberFormatException e) {
198+
logger.warn(
199+
"Cannot resolve @{} value '{}' as integer from property '{}'",
200+
annotation.getName(),
201+
raw,
202+
strValue);
203+
}
204+
}
205+
}
206+
207+
logger.warn(
208+
"Cannot resolve @{} annotation value '{}' to an integer – skipping validation",
209+
annotation.getName(),
210+
raw);
211+
return Optional.empty();
212+
}
213+
214+
/**
215+
* Builds an i18n message key following the Fiori elements override pattern. The specific key
216+
* {@code <baseKey>_<SimpleName>_<propertyName>} is always used; apps without a specific
217+
* translation entry will receive the message text from the generic {@code <baseKey>} fallback.
218+
*/
219+
static String messageKey(String baseKey, CdsEntity entity, String propertyName) {
220+
String simpleName = simpleEntityName(entity.getQualifiedName());
221+
return baseKey + "_" + simpleName + "_" + propertyName;
222+
}
223+
224+
private static String simpleEntityName(String qualifiedName) {
225+
int dot = qualifiedName.lastIndexOf('.');
226+
return dot >= 0 ? qualifiedName.substring(dot + 1) : qualifiedName;
227+
}
228+
}
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,111 @@
11
/*
2-
* © 2024-2025 SAP SE or an SAP affiliate company and cds-feature-attachments contributors.
2+
* © 2024-2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors.
33
*/
44
package com.sap.cds.feature.attachments.handler.draftservice;
55

66
import static java.util.Objects.requireNonNull;
77

8+
import com.sap.cds.CdsData;
9+
import com.sap.cds.feature.attachments.handler.applicationservice.helper.ItemCountValidator;
810
import com.sap.cds.feature.attachments.handler.applicationservice.helper.ThreadDataStorageSetter;
11+
import com.sap.cds.ql.CQL;
12+
import com.sap.cds.ql.Select;
13+
import com.sap.cds.reflect.CdsElement;
14+
import com.sap.cds.reflect.CdsEntity;
915
import com.sap.cds.services.draft.DraftSaveEventContext;
1016
import com.sap.cds.services.draft.DraftService;
1117
import com.sap.cds.services.handler.EventHandler;
18+
import com.sap.cds.services.handler.annotations.Before;
19+
import com.sap.cds.services.handler.annotations.HandlerOrder;
1220
import com.sap.cds.services.handler.annotations.On;
1321
import com.sap.cds.services.handler.annotations.ServiceName;
22+
import com.sap.cds.services.persistence.PersistenceService;
23+
import java.util.ArrayList;
24+
import java.util.List;
25+
import org.slf4j.Logger;
26+
import org.slf4j.LoggerFactory;
1427

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

31+
private static final Logger logger = LoggerFactory.getLogger(DraftActiveAttachmentsHandler.class);
32+
1833
private final ThreadDataStorageSetter threadLocalSetter;
34+
private final PersistenceService persistence;
1935

20-
public DraftActiveAttachmentsHandler(ThreadDataStorageSetter threadLocalSetter) {
36+
public DraftActiveAttachmentsHandler(
37+
ThreadDataStorageSetter threadLocalSetter, PersistenceService persistence) {
2138
this.threadLocalSetter =
2239
requireNonNull(threadLocalSetter, "threadLocalSetter must not be null");
40+
this.persistence = requireNonNull(persistence, "persistence must not be null");
41+
}
42+
43+
/**
44+
* Before draft save: validate min/max items as errors. Draft save = activating the draft, so an
45+
* invalid state is no longer tolerated and must result in an error.
46+
*/
47+
@Before
48+
@HandlerOrder(HandlerOrder.LATE)
49+
void validateItemCountBeforeSave(DraftSaveEventContext context) {
50+
CdsEntity entity = context.getTarget();
51+
52+
boolean hasItemCountAnnotations =
53+
entity.compositions().anyMatch(ItemCountValidator::hasItemCountAnnotation);
54+
if (!hasItemCountAnnotations) {
55+
return;
56+
}
57+
58+
logger.debug(
59+
"Validating item count before draft save for entity {}", entity.getQualifiedName());
60+
61+
CdsEntity draftEntity = DraftUtils.getDraftEntity(entity);
62+
List<CdsData> syntheticData = readDraftCompositionCounts(draftEntity, context, entity);
63+
ItemCountValidator.validate(entity, syntheticData, context, false);
2364
}
2465

2566
@On
2667
void processDraftSave(DraftSaveEventContext context) {
2768
threadLocalSetter.set(true, context::proceed);
2869
}
70+
71+
/**
72+
* Reads the draft composition data for all annotated compositions and builds a synthetic data
73+
* list suitable for {@link ItemCountValidator#validate}.
74+
*
75+
* <p>A SELECT with expand for each annotated composition is executed against the draft table. The
76+
* result is a single root {@link CdsData} entry whose composition arrays contain all found child
77+
* rows – this allows {@code ItemCountValidator} to count them correctly.
78+
*/
79+
private List<CdsData> readDraftCompositionCounts(
80+
CdsEntity draftEntity, DraftSaveEventContext context, CdsEntity activeEntity) {
81+
List<CdsElement> annotatedComps =
82+
activeEntity.compositions().filter(ItemCountValidator::hasItemCountAnnotation).toList();
83+
84+
var expandColumns =
85+
annotatedComps.stream().map(comp -> CQL.to(comp.getName()).expand()).toList();
86+
87+
// context.getCqn() is the CqnSelect that identifies the draft root entity (with keys/where).
88+
// We re-build a SELECT on the draft entity adding the expand columns for compositions.
89+
var baseCqn = context.getCqn();
90+
Select<?> select = Select.from(draftEntity).columns(expandColumns);
91+
baseCqn.where().ifPresent(select::where);
92+
93+
var result = persistence.run(select);
94+
List<CdsData> rows = result.listOf(CdsData.class);
95+
96+
// Aggregate: collect all composition items across root rows into one synthetic entry.
97+
// In practice draft save targets a single root entity, but we sum across all for safety.
98+
CdsData aggregated = CdsData.create();
99+
for (CdsElement comp : annotatedComps) {
100+
List<CdsData> allItems = new ArrayList<>();
101+
for (CdsData row : rows) {
102+
Object items = row.get(comp.getName());
103+
if (items instanceof List<?> list) {
104+
list.forEach(item -> allItems.add(CdsData.create()));
105+
}
106+
}
107+
aggregated.put(comp.getName(), allItems);
108+
}
109+
return List.of(aggregated);
110+
}
29111
}

0 commit comments

Comments
 (0)