Skip to content
Closed
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
44 changes: 41 additions & 3 deletions cds-feature-recommendations/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Typo: Missing space before "property" in the bullet point.

SAP_Recommendationspropertyshould beSAP_Recommendations property (space before "property").

Suggested change
- or adding the `SAP_Recommendations`property manually.
- or adding the `SAP_Recommendations` property manually.

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

Without the `SAP_Recommendations` navigation property, the predictions will be computed but not serialized in OData responses.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Clarity: The warning about missing SAP_Recommendations navigation property is misplaced. It is indented at the same level as the sub-bullets, but it applies to the overall prerequisite and should be visually separated from the list items so readers don't mistake it for a third option.

Consider placing it as a standalone note below the two sub-bullets, or dedenting it to the parent bullet level, e.g.:

- 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.

  > **Note:** Without the `SAP_Recommendations` navigation property, the predictions will be computed but not serialized in OData responses.

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


### CDS Plugin
#### CDS Plugin

Add `@cap-js/ai` to your project's `package.json`:

Expand All @@ -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`:

Expand All @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Boolean> entitiesWithoutPredictions =
// Avoids re-evaluating the CDS model on every read to check whether an entity has prediction
// columns. Keys are "<tenantId>:<entityName>" because if an entity needs a prediction can be
// different across tenants.
private final Cache<String, Boolean> entitiesWithoutPredictionsPerTenant =
Caffeine.newBuilder().maximumSize(10_000).build();

FioriRecommendationHandler(
Expand All @@ -40,14 +43,25 @@ class FioriRecommendationHandler implements EventHandler {
this.clientResolver = clientResolver;
}

void invalidateTenant(String tenantId) {
String prefix = tenantKey(tenantId) + ":";
Comment on lines +46 to +47

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: invalidateTenant prefix collision — a tenant whose ID is a prefix of another tenant's ID will incorrectly evict the other tenant's cache entries.

For example, if two tenants are named "t" and "t2", calling invalidateTenant("t") builds the prefix "t:". The key for tenant "t2" is "t2:<entity>" which does not start with "t:", so this particular pair is actually safe. However, consider tenants "tenant" and "tenant-extended": after invalidateTenant("tenant"), the prefix is "tenant:" and any key beginning with "tenant:" will be removed — that is correct. But consider two tenants "a" and "ab": prefix "a:" will only match "a:…", not "ab:…", because of the colon separator, so the colon already prevents simple substring collisions.

The real remaining risk is that a malicious or misconfigured tenant ID that itself contains ":" (e.g. "t:en") would produce tenantKey("t:en") + ":" = "t:en:", which could match cache keys for tenant "t" whose entity name starts with "en:…". While that is an edge case, the separator character used for the composite key should be one that cannot appear in either component, or the components should be length-prefixed / otherwise escaped.

Consider using a separator that cannot appear in tenant IDs and entity names, or use a Map<String, Map<String, Boolean>> (tenant → entity-name → flag) to avoid any composite-key ambiguity entirely.


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

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<CdsData> 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;
}

Expand Down Expand Up @@ -82,7 +96,7 @@ public void afterRead(CdsReadEventContext context, List<CdsData> dataList) {
var builder = new RecommendationContextBuilder(target, rowType, limit);

if (builder.predictionElementNames().isEmpty()) {
entitiesWithoutPredictions.put(entityName, Boolean.TRUE);
entitiesWithoutPredictionsPerTenant.put(cacheKey, Boolean.TRUE);
return;
}

Expand All @@ -109,7 +123,6 @@ public void afterRead(CdsReadEventContext context, List<CdsData> dataList) {

List<CdsData> allRows = builder.assembleRows(contextRows, predictRow, row);

String tenantId = context.getUserInfo().getTenant();
RecommendationClient client = clientResolver.resolve(aiCoreService, tenantId);
List<CdsData> predictions =
client.predict(allRows, builder.predictionElementNames(), builder.indexColumn());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,50 @@ void rptStyleClient_filledColumns_areExcludedFromRecommendations() {
});
}

@Test
void invalidateTenant_removesOnlyThatTenantsEntries() {
// populate cache for two tenants
runInTenant(
"tenant-a",
() -> {
Map<String, Object> row = draftRow("title", "foo");
CdsReadEventContext ctx = readContext("test.PlainEntity", List.of(row));
cut.afterRead(ctx, dataList(row));
});
runInTenant(
"tenant-b",
() -> {
Map<String, Object> 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<String, Object> 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<String, Object> 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<Map<String, Object>> resultRows) {
Expand All @@ -317,6 +361,10 @@ private void runIn(Runnable test) {
runtime.requestContext().run((Consumer<RequestContext>) rc -> test.run());
}

private void runInTenant(String tenantId, Runnable test) {
runtime.requestContext().systemUser(tenantId).run((Consumer<RequestContext>) rc -> test.run());
}

private Map<String, Object> draftRow(String col, Object val) {
Map<String, Object> row = new HashMap<>();
row.put("ID", "a009c640-434a-4542-ac68-51b400c880ec");
Expand Down
Loading