diff --git a/cds-feature-recommendations/README.md b/cds-feature-recommendations/README.md index 9b59766..6aee06f 100644 --- a/cds-feature-recommendations/README.md +++ b/cds-feature-recommendations/README.md @@ -40,9 +40,12 @@ Or use the starter that bundles this with `cds-feature-ai-core`: - An [SAP AI Core](https://help.sap.com/docs/sap-ai-core) service binding (see [`cds-feature-ai-core`](../cds-feature-ai-core/README.md)) - Entity must be **draft-enabled** (`@odata.draft.enabled`) - At least one field annotated with a **value list** -- The `@cap-js/ai` CDS plugin must be installed (provides the model enhancement that adds `SAP_Recommendations` as a navigation property) +- The `SAP_Recommendations` navigation property must be added to the entities that should receive recommendations by + - either installing the `@cap-js/ai` CDS plugin (automatically provides the model enhancement that adds `SAP_Recommendations` as a navigation property) + - or adding the `SAP_Recommendations`property manually. + Without the `SAP_Recommendations` navigation property, the predictions will be computed but not serialized in OData responses. -### CDS Plugin +#### CDS Plugin Add `@cap-js/ai` to your project's `package.json`: @@ -55,7 +58,7 @@ Add `@cap-js/ai` to your project's `package.json`: } ``` -Then run `npm install`. The plugin hooks into the CDS compiler and automatically adds the `SAP_Recommendations` navigation property to draft-enabled entities that have value-list fields. Without this plugin, predictions will be computed but not serialized in OData responses. +Then run `npm install`. The plugin hooks into the CDS compiler and automatically adds the `SAP_Recommendations` navigation property to draft-enabled entities that have value-list fields. Since the Java module `cds-feature-ai-core` already provides the `AICore` service CDS model, disable the duplicate model from `@cap-js/ai` in your `.cdsrc.json`: @@ -68,6 +71,41 @@ Since the Java module `cds-feature-ai-core` already provides the `AICore` servic } } ``` +#### Adding the SAP_Recommendations navigation property manually + +If you cannot use the CDS plugin, add the `SAP_Recommendations` navigation property directly in your CDS model. You need to: + +1. **Define a `RecommendationItem_*` type** for each CDS primitive type used by your value-list fields. Each type must contain the four fixed fields shown below — only `RecommendedFieldValue` varies by type. +2. **Extend each target entity** with a `SAP_Recommendations` composition that has one entry per value-list field, using the field name as the property name and the matching `RecommendationItem_*` type. + +The property names inside `SAP_Recommendations` must exactly match the field names on the entity (e.g. `genre_ID`, `author_ID`). + +```cds +// Define one type per CDS primitive used by your value-list fields +type RecommendationItem_Integer { + RecommendedFieldValue : Integer; + RecommendedFieldDescription : String; + RecommendedFieldScoreValue : Decimal; + RecommendedFieldIsSuggestion: Boolean; +} + +type RecommendationItem_UUID { + RecommendedFieldValue : UUID; + RecommendedFieldDescription : String; + RecommendedFieldScoreValue : Decimal; + RecommendedFieldIsSuggestion: Boolean; +} + +// Extend your entity — one entry per value-list field +extend my.Books with { + SAP_Recommendations: Composition of one { + genre_ID : many RecommendationItem_Integer; + author_ID: many RecommendationItem_UUID; + } +} +``` + +See also the [SAP Fiori Elements – Recommendations documentation](https://help.sap.com/docs/SAPUI5/b2f662dd9d7a4ec680056733050b4d34/1a6324d5ad7f4034a93f911b4e53e080.html). ## Enabling Recommendations diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java index 4771c9d..dc26ca4 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java @@ -31,7 +31,10 @@ class FioriRecommendationHandler implements EventHandler { private final AICoreService aiCoreService; private final RecommendationClientResolver clientResolver; private final RecommendationResultParser resultParser = new RecommendationResultParser(); - private final Cache entitiesWithoutPredictions = + // Avoids re-evaluating the CDS model on every read to check whether an entity has prediction + // columns. Keys are ":" because if an entity needs a prediction can be + // different across tenants. + private final Cache entitiesWithoutPredictionsPerTenant = Caffeine.newBuilder().maximumSize(10_000).build(); FioriRecommendationHandler( @@ -40,14 +43,25 @@ class FioriRecommendationHandler implements EventHandler { this.clientResolver = clientResolver; } + void invalidateTenant(String tenantId) { + String prefix = tenantKey(tenantId) + ":"; + entitiesWithoutPredictionsPerTenant.asMap().keySet().removeIf(k -> k.startsWith(prefix)); + } + + private static String tenantKey(String tenantId) { + return tenantId != null ? tenantId : ""; + } + @After(entity = "*") public void afterRead(CdsReadEventContext context, List dataList) { CdsStructuredType target = context.getTarget(); if (target == null) { return; } + String tenantId = context.getUserInfo().getTenant(); String entityName = target.getQualifiedName(); - if (entitiesWithoutPredictions.getIfPresent(entityName) != null) { + String cacheKey = tenantKey(tenantId) + ":" + entityName; + if (entitiesWithoutPredictionsPerTenant.getIfPresent(cacheKey) != null) { return; } @@ -82,7 +96,7 @@ public void afterRead(CdsReadEventContext context, List dataList) { var builder = new RecommendationContextBuilder(target, rowType, limit); if (builder.predictionElementNames().isEmpty()) { - entitiesWithoutPredictions.put(entityName, Boolean.TRUE); + entitiesWithoutPredictionsPerTenant.put(cacheKey, Boolean.TRUE); return; } @@ -109,7 +123,6 @@ public void afterRead(CdsReadEventContext context, List dataList) { List allRows = builder.assembleRows(contextRows, predictRow, row); - String tenantId = context.getUserInfo().getTenant(); RecommendationClient client = clientResolver.resolve(aiCoreService, tenantId); List predictions = client.predict(allRows, builder.predictionElementNames(), builder.indexColumn()); diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationConfiguration.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationConfiguration.java index 427d8a4..ee85b46 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationConfiguration.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationConfiguration.java @@ -34,7 +34,9 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) { ? (service, tenantId) -> new MockRecommendationClient() : RecommendationConfiguration::resolveRptClient; - configurer.eventHandler(new FioriRecommendationHandler(aiCoreService, resolver)); + FioriRecommendationHandler handler = new FioriRecommendationHandler(aiCoreService, resolver); + configurer.eventHandler(handler); + configurer.eventHandler(new RecommendationModelChangedHandler(handler)); } private static RecommendationClient resolveRptClient(AICoreService service, String tenantId) { diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationModelChangedHandler.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationModelChangedHandler.java new file mode 100644 index 0000000..edeebd7 --- /dev/null +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationModelChangedHandler.java @@ -0,0 +1,26 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.recommendation; + +import com.sap.cds.services.handler.EventHandler; +import com.sap.cds.services.handler.annotations.On; +import com.sap.cds.services.handler.annotations.ServiceName; +import com.sap.cds.services.mt.ExtensibilityService; +import com.sap.cds.services.mt.ModelChangedEventContext; + +@ServiceName(value = ExtensibilityService.DEFAULT_NAME, type = ExtensibilityService.class) +class RecommendationModelChangedHandler implements EventHandler { + + private final FioriRecommendationHandler recommendationHandler; + + RecommendationModelChangedHandler(FioriRecommendationHandler recommendationHandler) { + this.recommendationHandler = recommendationHandler; + } + + @On(event = ExtensibilityService.EVENT_MODEL_CHANGED) + public void onModelChanged(ModelChangedEventContext context) { + String tenantId = context.getUserInfo().getTenant(); + recommendationHandler.invalidateTenant(tenantId); + } +} diff --git a/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/FioriRecommendationHandlerTest.java b/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/FioriRecommendationHandlerTest.java index 6ab17fa..6434ce7 100644 --- a/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/FioriRecommendationHandlerTest.java +++ b/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/FioriRecommendationHandlerTest.java @@ -296,6 +296,50 @@ void rptStyleClient_filledColumns_areExcludedFromRecommendations() { }); } + @Test + void invalidateTenant_removesOnlyThatTenantsEntries() { + // populate cache for two tenants + runInTenant( + "tenant-a", + () -> { + Map row = draftRow("title", "foo"); + CdsReadEventContext ctx = readContext("test.PlainEntity", List.of(row)); + cut.afterRead(ctx, dataList(row)); + }); + runInTenant( + "tenant-b", + () -> { + Map row = draftRow("title", "foo"); + CdsReadEventContext ctx = readContext("test.PlainEntity", List.of(row)); + cut.afterRead(ctx, dataList(row)); + }); + + cut.invalidateTenant("tenant-a"); + + // tenant-a entry gone → db is called again (no early exit) + runInTenant( + "tenant-a", + () -> { + Map row = draftRow("genre_ID", null); + CdsReadEventContext ctx = readContext("test.Books", List.of(row)); + when(db.run(any(CqnSelect.class))).thenReturn(twoContextRows()); + cut.afterRead(ctx, dataList(row)); + assertThat(row).containsKey("SAP_Recommendations"); + }); + + // tenant-b entry still cached → db is NOT called + reset(db); + when(db.getName()).thenReturn(PersistenceService.DEFAULT_NAME); + runInTenant( + "tenant-b", + () -> { + Map row = draftRow("title", "foo"); + CdsReadEventContext ctx = readContext("test.PlainEntity", List.of(row)); + cut.afterRead(ctx, dataList(row)); + verifyNoInteractions(db); + }); + } + // ── helpers ──────────────────────────────────────────────────────────────── private CdsReadEventContext readContext(String entityName, List> resultRows) { @@ -317,6 +361,10 @@ private void runIn(Runnable test) { runtime.requestContext().run((Consumer) rc -> test.run()); } + private void runInTenant(String tenantId, Runnable test) { + runtime.requestContext().systemUser(tenantId).run((Consumer) rc -> test.run()); + } + private Map draftRow(String col, Object val) { Map row = new HashMap<>(); row.put("ID", "a009c640-434a-4542-ac68-51b400c880ec");