|
| 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 | +} |
0 commit comments