From e7e925feb9fe56657626496bb2b7636ebd54a065 Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Mon, 15 Jun 2026 16:51:42 +0200 Subject: [PATCH 01/34] Separate the predictionRow from the contextRows and add a constant for [PREDICT] --- .../FioriRecommendationHandler.java | 8 ++- .../MockRecommendationClient.java | 46 ++++++------- .../RecommendationContextBuilder.java | 30 +++++---- .../api/RecommendationClient.java | 12 +++- .../api/RptInferenceClient.java | 20 ++++-- .../FioriRecommendationHandlerTest.java | 64 ++++++------------- 6 files changed, 89 insertions(+), 91 deletions(-) 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 36e9634..56d6ed4 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 @@ -121,11 +121,15 @@ public void afterRead(CdsReadEventContext context, List dataList) { return; } - List allRows = builder.assembleRows(contextRows, predictRow, row); + // RPT-1 requires a single string index column; entities with composite or non-ID keys get a + // synthetic key injected before the rows are sent to the model. + builder.addSyntheticKey(contextRows); + builder.addSyntheticKey(predictRow); RecommendationClient client = clientResolver.resolve(aiCoreService); List predictions = - client.predict(allRows, builder.predictionElementNames(), builder.indexColumn()); + client.predict( + predictRow, contextRows, builder.predictionElementNames(), builder.indexColumn()); if (predictions.isEmpty()) { logger.warn("No predictions returned from AI client."); diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/MockRecommendationClient.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/MockRecommendationClient.java index 27498bb..42d0b19 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/MockRecommendationClient.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/MockRecommendationClient.java @@ -5,7 +5,7 @@ import com.sap.cds.CdsData; import com.sap.cds.feature.recommendation.api.RecommendationClient; -import java.util.ArrayList; +import com.sap.cds.feature.recommendation.api.RptInferenceClient; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -17,33 +17,25 @@ class MockRecommendationClient implements RecommendationClient { @Override public List predict( - List rows, List predictionColumns, String indexColumn) { - List predictions = new ArrayList<>(); - for (CdsData row : rows) { - Map prediction = new HashMap<>(); - boolean addPrediction = false; - for (String col : predictionColumns) { - if ("[PREDICT]".equals(row.get(col))) { - addPrediction = true; - List availableValues = - rows.stream() - .filter(r -> r.get(col) != null && !"[PREDICT]".equals(r.get(col))) - .map(r -> r.get(col)) - .toList(); - Object contextValue = - availableValues.isEmpty() - ? null - : availableValues.get(random.nextInt(availableValues.size())); - Map predictionEntry = new HashMap<>(); - predictionEntry.put("prediction", contextValue); - prediction.put(col, List.of(predictionEntry)); - } - } - if (addPrediction) { - prediction.put(indexColumn, row.get(indexColumn)); - predictions.add(CdsData.create(prediction)); + CdsData predictionRow, + List contextRows, + List predictionColumns, + String indexColumn) { + Map prediction = new HashMap<>(); + for (String col : predictionColumns) { + if (RptInferenceClient.PREDICT.equals(predictionRow.get(col))) { + List availableValues = + contextRows.stream().filter(r -> r.get(col) != null).map(r -> r.get(col)).toList(); + Object contextValue = + availableValues.isEmpty() + ? null + : availableValues.get(random.nextInt(availableValues.size())); + Map predictionEntry = new HashMap<>(); + predictionEntry.put("prediction", contextValue); + prediction.put(col, List.of(predictionEntry)); } } - return predictions; + prediction.put(indexColumn, predictionRow.get(indexColumn)); + return List.of(CdsData.create(prediction)); } } diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationContextBuilder.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationContextBuilder.java index a158123..c15d24c 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationContextBuilder.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationContextBuilder.java @@ -6,6 +6,7 @@ import static com.sap.cds.reflect.CdsAnnotatable.byAnnotation; import com.sap.cds.CdsData; +import com.sap.cds.feature.recommendation.api.RptInferenceClient; import com.sap.cds.ql.CQL; import com.sap.cds.ql.Select; import com.sap.cds.ql.cqn.CqnSelect; @@ -128,7 +129,7 @@ CdsData buildPredictRow(CdsData row) { Map predictRow = new HashMap<>(row); Drafts.ELEMENTS.forEach(predictRow::remove); for (String col : predictionElementNames) { - predictRow.putIfAbsent(col, "[PREDICT]"); + predictRow.putIfAbsent(col, RptInferenceClient.PREDICT); } return CdsData.create(predictRow); } @@ -149,19 +150,24 @@ private String computeSyntheticKey(Map row) { return sb.toString(); } - List assembleRows(List contextRows, CdsData predictRow, CdsData currentRow) { - List allRows = new ArrayList<>(); + /** + * Injects a synthetic index column into the given row if the entity has a composite or non-ID + * key. The synthetic key is a concatenation of all key fields, used as the RPT-1 index column. + * No-op when a plain {@code ID} key is sufficient. + */ + void addSyntheticKey(CdsData row) { if (syntheticKeyNeeded) { - for (CdsData contextRow : contextRows) { - contextRow.put(SYNTHETIC_KEY_COLUMN, computeSyntheticKey(contextRow)); - allRows.add(contextRow); - } - predictRow.put(SYNTHETIC_KEY_COLUMN, computeSyntheticKey(currentRow)); - } else { - allRows.addAll(contextRows); + row.put(SYNTHETIC_KEY_COLUMN, computeSyntheticKey(row)); + } + } + + /** + * Convenience overload that applies {@link #addSyntheticKey(CdsData)} to each row in the list. + */ + void addSyntheticKey(List rows) { + if (syntheticKeyNeeded) { + rows.forEach(this::addSyntheticKey); } - allRows.add(predictRow); - return allRows; } private List computePredictionElements() { diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RecommendationClient.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RecommendationClient.java index 8694745..c2c036c 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RecommendationClient.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RecommendationClient.java @@ -8,5 +8,15 @@ public interface RecommendationClient { - List predict(List rows, List predictionColumns, String indexColumn); + // Currently limited to a single prediction row. Multiple prediction rows may be supported in the + // future via a separate overload, but are ruled out at two points for now: + // (1) FioriRecommendationHandler bails out when the read returns more than one entity, + // so predictions only fire on single-entity reads. + // (2) FioriRecommendationHandler also rejects responses with more than one prediction back from + // the model, treating it as an unexpected state. + List predict( + CdsData predictionRow, + List contextRows, + List predictionColumns, + String indexColumn); } diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java index c6e4c3a..5de4d76 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java @@ -44,6 +44,9 @@ public class RptInferenceClient implements RecommendationClient { private static final Logger logger = LoggerFactory.getLogger(RptInferenceClient.class); + // RPT-1 specific: the placeholder value that marks a column as a prediction target in the request + public static final String PREDICT = "[PREDICT]"; + private static final Set MANAGED_FIELDS = Set.of("createdBy", "modifiedBy", "createdAt", "modifiedAt"); @@ -58,11 +61,16 @@ public RptInferenceClient(ApiClient apiClient) { @Override public List predict( - List rows, List predictionColumns, String indexColumn) { - PredictRequestPayload request = buildRequest(rows, predictionColumns, indexColumn); + CdsData predictionRow, + List contextRows, + List predictionColumns, + String indexColumn) { + List allRows = new java.util.ArrayList<>(contextRows); + allRows.add(predictionRow); + PredictRequestPayload request = buildRequest(allRows, predictionColumns, indexColumn); logger.debug( - "Sending prediction request for {} rows, {} target columns", - rows.size(), + "Sending prediction request for one row with {} context rows, {} target columns", + contextRows.size(), predictionColumns.size()); return Retry.decorateSupplier( INFERENCE_RETRY, @@ -85,7 +93,7 @@ private static PredictRequestPayload buildRequest( col -> TargetColumnConfig.create() .name(col) - .predictionPlaceholder(PredictionPlaceholder.create("[PREDICT]")) + .predictionPlaceholder(PredictionPlaceholder.create(PREDICT)) .taskType(TargetColumnConfig.TaskTypeEnum.CLASSIFICATION)) .toList(); @@ -104,7 +112,7 @@ private static PredictRequestPayload buildRequest( }); for (String target : predictionColumns) { if (!row.containsKey(target) || row.get(target) == null) { - sdkRow.put(target, RowsInnerValue.create("[PREDICT]")); + sdkRow.put(target, RowsInnerValue.create(PREDICT)); } } return sdkRow; 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 c7f1a22..fe3ac39 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 @@ -152,7 +152,7 @@ void emptyPredictions_returnsEarlyWithoutRecommendations() { Map row = draftRow("genre_ID", null); CdsReadEventContext ctx = readContext("test.Books", List.of(row)); when(db.run(any(CqnSelect.class))).thenReturn(twoContextRows()); - predictionClient = (rows, cols, idx) -> List.of(); + predictionClient = (predictionRow, contextRows, cols, idx) -> List.of(); cut.afterRead(ctx, dataList(row)); assertThat(row).doesNotContainKey("SAP_Recommendations"); }); @@ -166,7 +166,7 @@ void multiplePredictions_returnsEarlyWithoutRecommendations() { CdsReadEventContext ctx = readContext("test.Books", List.of(row)); when(db.run(any(CqnSelect.class))).thenReturn(twoContextRows()); predictionClient = - (rows, cols, idx) -> + (predictionRow, contextRows, cols, idx) -> List.of( CdsData.create(Map.of("ID", "id-1")), CdsData.create(Map.of("ID", "id-2"))); cut.afterRead(ctx, dataList(row)); @@ -393,55 +393,33 @@ private static Result twoContextRows() { private static RecommendationClient rptStyleClient() { Random random = new Random(42); - return (rows, predictionColumns, indexColumn) -> { - List predictions = new ArrayList<>(); - for (CdsData row : rows) { - if (predictionColumns.stream().noneMatch(col -> "[PREDICT]".equals(row.get(col)))) { - continue; - } - Map prediction = new HashMap<>(); - for (String col : predictionColumns) { - List available = - rows.stream() - .filter(r -> r.get(col) != null && !"[PREDICT]".equals(r.get(col))) - .map(r -> r.get(col)) - .toList(); - Object val = available.isEmpty() ? null : available.get(random.nextInt(available.size())); - prediction.put(col, List.of(Map.of("prediction", val))); - } - prediction.put(indexColumn, row.get(indexColumn)); - predictions.add(CdsData.create(prediction)); + return (predictionRow, contextRows, predictionColumns, indexColumn) -> { + Map prediction = new HashMap<>(); + for (String col : predictionColumns) { + List available = + contextRows.stream().filter(r -> r.get(col) != null).map(r -> r.get(col)).toList(); + Object val = available.isEmpty() ? null : available.get(random.nextInt(available.size())); + prediction.put(col, List.of(Map.of("prediction", val))); } - return predictions; + prediction.put(indexColumn, predictionRow.get(indexColumn)); + return List.of(CdsData.create(prediction)); }; } private static RecommendationClient randomPickClient() { Random random = new Random(42); - return (rows, predictionColumns, indexColumn) -> { - List predictions = new ArrayList<>(); - for (CdsData row : rows) { - Map prediction = new HashMap<>(); - boolean addPrediction = false; - for (String col : predictionColumns) { - if ("[PREDICT]".equals(row.get(col))) { - addPrediction = true; - List available = - rows.stream() - .filter(r -> r.get(col) != null && !"[PREDICT]".equals(r.get(col))) - .map(r -> r.get(col)) - .toList(); - Object val = - available.isEmpty() ? null : available.get(random.nextInt(available.size())); - prediction.put(col, List.of(Map.of("prediction", val))); - } - } - if (addPrediction) { - prediction.put(indexColumn, row.get(indexColumn)); - predictions.add(CdsData.create(prediction)); + return (predictionRow, contextRows, predictionColumns, indexColumn) -> { + Map prediction = new HashMap<>(); + for (String col : predictionColumns) { + if ("[PREDICT]".equals(predictionRow.get(col))) { + List available = + contextRows.stream().filter(r -> r.get(col) != null).map(r -> r.get(col)).toList(); + Object val = available.isEmpty() ? null : available.get(random.nextInt(available.size())); + prediction.put(col, List.of(Map.of("prediction", val))); } } - return predictions; + prediction.put(indexColumn, predictionRow.get(indexColumn)); + return List.of(CdsData.create(prediction)); }; } } From c2b35eda0e48739d6960b22d1ffcdeef1ba00e34 Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Mon, 15 Jun 2026 17:19:09 +0200 Subject: [PATCH 02/34] Add comment on why we the @FunctionalInterface annotation is useful --- .../recommendation/api/RecommendationClientResolver.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RecommendationClientResolver.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RecommendationClientResolver.java index ecc6837..2f83755 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RecommendationClientResolver.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RecommendationClientResolver.java @@ -5,6 +5,8 @@ import com.sap.cds.feature.aicore.api.AICoreService; +// The annotation @FunctionalInterface ensures this interface has only one method, such that +// callers can supply a custom client by providing this one method e.g. via a lambda. @FunctionalInterface public interface RecommendationClientResolver { From 5437c97326b7b1202e95b45e1f0c10d899f1fc4a Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Tue, 16 Jun 2026 08:18:01 +0200 Subject: [PATCH 03/34] Add comment to FioriRecommendationHandler --- .../cds/feature/recommendation/FioriRecommendationHandler.java | 2 ++ 1 file changed, 2 insertions(+) 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 56d6ed4..8d07153 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 @@ -81,6 +81,8 @@ public void afterRead(CdsReadEventContext context, List dataList) { return; } + // rowType reflects the projected shape (columns actually selected); target is the full entity. + // Fall back to target when rowType is absent, e.g. when the result carries no type metadata. CdsStructuredType rowType = context.getResult().rowType(); if (rowType == null) { rowType = target; From 3bd12c7121762a8cc7aec0b3a154e2df26e9569a Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Tue, 16 Jun 2026 11:14:44 +0200 Subject: [PATCH 04/34] Moved everthing RPT-1 specific into RptInferenceClient --- .../FioriRecommendationHandler.java | 7 +-- .../MockRecommendationClient.java | 8 +-- .../RecommendationContextBuilder.java | 58 ++----------------- .../api/RecommendationClient.java | 8 ++- .../api/RptInferenceClient.java | 47 ++++++++++++++- .../FioriRecommendationHandlerTest.java | 12 ++-- 6 files changed, 67 insertions(+), 73 deletions(-) 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 8d07153..8cb17ef 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 @@ -123,15 +123,10 @@ public void afterRead(CdsReadEventContext context, List dataList) { return; } - // RPT-1 requires a single string index column; entities with composite or non-ID keys get a - // synthetic key injected before the rows are sent to the model. - builder.addSyntheticKey(contextRows); - builder.addSyntheticKey(predictRow); - RecommendationClient client = clientResolver.resolve(aiCoreService); List predictions = client.predict( - predictRow, contextRows, builder.predictionElementNames(), builder.indexColumn()); + predictRow, contextRows, builder.predictionElementNames(), builder.keyNames()); if (predictions.isEmpty()) { logger.warn("No predictions returned from AI client."); diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/MockRecommendationClient.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/MockRecommendationClient.java index 42d0b19..c4a4540 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/MockRecommendationClient.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/MockRecommendationClient.java @@ -5,7 +5,6 @@ import com.sap.cds.CdsData; import com.sap.cds.feature.recommendation.api.RecommendationClient; -import com.sap.cds.feature.recommendation.api.RptInferenceClient; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -20,10 +19,11 @@ public List predict( CdsData predictionRow, List contextRows, List predictionColumns, - String indexColumn) { + List keyNames) { + String indexColumn = keyNames.size() == 1 ? keyNames.get(0) : "SAP_RECOMMENDATIONS_ID"; Map prediction = new HashMap<>(); for (String col : predictionColumns) { - if (RptInferenceClient.PREDICT.equals(predictionRow.get(col))) { + if (predictionRow.get(col) == null) { List availableValues = contextRows.stream().filter(r -> r.get(col) != null).map(r -> r.get(col)).toList(); Object contextValue = @@ -35,7 +35,7 @@ public List predict( prediction.put(col, List.of(predictionEntry)); } } - prediction.put(indexColumn, predictionRow.get(indexColumn)); + prediction.put(indexColumn, predictionRow.get(keyNames.get(0))); return List.of(CdsData.create(prediction)); } } diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationContextBuilder.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationContextBuilder.java index c15d24c..8db5d24 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationContextBuilder.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationContextBuilder.java @@ -6,7 +6,6 @@ import static com.sap.cds.reflect.CdsAnnotatable.byAnnotation; import com.sap.cds.CdsData; -import com.sap.cds.feature.recommendation.api.RptInferenceClient; import com.sap.cds.ql.CQL; import com.sap.cds.ql.Select; import com.sap.cds.ql.cqn.CqnSelect; @@ -24,14 +23,14 @@ /** * Builds the context data needed for prediction: determines which elements to predict, which - * columns provide context, builds the context query, and prepares rows for the AI model. + * columns provide context and builds the context query. This class is cds-model aware, but does not + * know about which client will be used for the predictions. */ class RecommendationContextBuilder { private static final String VALUE_LIST_ANNOTATION = "@Common.ValueList"; private static final String VALUE_LIST_WITH_FIXED_VALUES_ANNOTATION = "@Common.ValueListWithFixedValues"; - private static final String SYNTHETIC_KEY_COLUMN = "SAP_RECOMMENDATIONS_ID"; private static final Set SUPPORTED_CONTEXT_TYPES = EnumSet.of( CdsBaseType.STRING, @@ -65,8 +64,6 @@ class RecommendationContextBuilder { private final List predictionElementNames; private final List contextColumns; private final List keyNames; - private final boolean syntheticKeyNeeded; - private final String indexColumn; RecommendationContextBuilder(CdsStructuredType target, CdsStructuredType rowType, int limit) { this.target = target; @@ -75,10 +72,6 @@ class RecommendationContextBuilder { this.predictionElementNames = computePredictionElements(); this.contextColumns = computeContextColumns(); this.keyNames = target.keyElements().map(CdsElement::getName).toList(); - this.syntheticKeyNeeded = - keyNames.size() > 1 || (keyNames.size() == 1 && !"ID".equals(keyNames.get(0))); - this.indexColumn = - syntheticKeyNeeded ? SYNTHETIC_KEY_COLUMN : keyNames.stream().findFirst().orElse("ID"); } List predictionElementNames() { @@ -89,12 +82,8 @@ List contextColumns() { return contextColumns; } - String indexColumn() { - return indexColumn; - } - - boolean syntheticKeyNeeded() { - return syntheticKeyNeeded; + List keyNames() { + return keyNames; } CqnSelect buildContextQuery() { @@ -128,48 +117,9 @@ CdsData buildPredictRow(CdsData row) { } Map predictRow = new HashMap<>(row); Drafts.ELEMENTS.forEach(predictRow::remove); - for (String col : predictionElementNames) { - predictRow.putIfAbsent(col, RptInferenceClient.PREDICT); - } return CdsData.create(predictRow); } - private String computeSyntheticKey(Map row) { - StringBuilder sb = new StringBuilder(); - for (int i = 0; i < keyNames.size(); i++) { - if (i > 0) { - sb.append('\0'); - } - sb.append(keyNames.get(i)); - sb.append('\0'); - Object value = row.get(keyNames.get(i)); - if (value != null) { - sb.append(value); - } - } - return sb.toString(); - } - - /** - * Injects a synthetic index column into the given row if the entity has a composite or non-ID - * key. The synthetic key is a concatenation of all key fields, used as the RPT-1 index column. - * No-op when a plain {@code ID} key is sufficient. - */ - void addSyntheticKey(CdsData row) { - if (syntheticKeyNeeded) { - row.put(SYNTHETIC_KEY_COLUMN, computeSyntheticKey(row)); - } - } - - /** - * Convenience overload that applies {@link #addSyntheticKey(CdsData)} to each row in the list. - */ - void addSyntheticKey(List rows) { - if (syntheticKeyNeeded) { - rows.forEach(this::addSyntheticKey); - } - } - private List computePredictionElements() { return rowType .elements() diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RecommendationClient.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RecommendationClient.java index c2c036c..1170780 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RecommendationClient.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RecommendationClient.java @@ -14,9 +14,15 @@ public interface RecommendationClient { // so predictions only fire on single-entity reads. // (2) FioriRecommendationHandler also rejects responses with more than one prediction back from // the model, treating it as an unexpected state. + // + // @param predictionRow the single entity row to predict values for; prediction columns contain + // null for missing values that the model should fill + // @param contextRows historical rows from the same entity used as training context + // @param predictionColumns names of the columns the model should predict + // @param keyNames names of the entity's key columns, used to identify rows in the response List predict( CdsData predictionRow, List contextRows, List predictionColumns, - String indexColumn); + List keyNames); } diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java index 5de4d76..9639d8b 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java @@ -64,9 +64,13 @@ public List predict( CdsData predictionRow, List contextRows, List predictionColumns, - String indexColumn) { + List keyNames) { + String indexColumn = resolveIndexColumn(keyNames); + CdsData preparedPredictRow = preparePredictRow(predictionRow, predictionColumns); List allRows = new java.util.ArrayList<>(contextRows); - allRows.add(predictionRow); + allRows.add(preparedPredictRow); + addSyntheticKeyIfNeeded(allRows, keyNames, indexColumn); + PredictRequestPayload request = buildRequest(allRows, predictionColumns, indexColumn); logger.debug( "Sending prediction request for one row with {} context rows, {} target columns", @@ -85,6 +89,45 @@ public List predict( .get(); } + // RPT-1 specific: when the entity has a composite or non-ID key, a synthetic string index column + // is computed by concatenating all key fields and injected into each row before sending. + private static final String SYNTHETIC_INDEX_COLUMN = "SAP_RECOMMENDATIONS_ID"; + + // If there is one key, use it directly and don't compute a synthetic key + private static String resolveIndexColumn(List keyNames) { + return (keyNames.size() == 1 && "ID".equals(keyNames.get(0))) ? "ID" : SYNTHETIC_INDEX_COLUMN; + } + + // Compute the synthetic key as a concatenated string from the keys listed in keyNames + private static String computeSyntheticKey(Map row, List keyNames) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < keyNames.size(); i++) { + if (i > 0) sb.append('\0'); + sb.append(keyNames.get(i)).append('\0'); + Object value = row.get(keyNames.get(i)); + if (value != null) sb.append(value); + } + return sb.toString(); + } + + private static void addSyntheticKeyIfNeeded( + List rows, List keyNames, String indexColumn) { + if (SYNTHETIC_INDEX_COLUMN.equals(indexColumn)) { + rows.forEach(r -> r.put(SYNTHETIC_INDEX_COLUMN, computeSyntheticKey(r, keyNames))); + } + } + + // Returns a copy of the predictRow without the fields in Drafts.ELEMENTS and with a + // prediction placeholder for empty values in the predictinonColumns + private static CdsData preparePredictRow(CdsData predictRow, List predictionColumns) { + Map preparedPredictRowMap = new HashMap<>(predictRow); + // Drafts.ELEMENTS.forEach(preparedPredictRowMap::remove); + for (String col : predictionColumns) { + preparedPredictRowMap.putIfAbsent(col, PREDICT); + } + return CdsData.create(preparedPredictRowMap); + } + private static PredictRequestPayload buildRequest( List rows, List predictionColumns, String indexColumn) { var targetColumns = 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 fe3ac39..efe4700 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 @@ -152,7 +152,7 @@ void emptyPredictions_returnsEarlyWithoutRecommendations() { Map row = draftRow("genre_ID", null); CdsReadEventContext ctx = readContext("test.Books", List.of(row)); when(db.run(any(CqnSelect.class))).thenReturn(twoContextRows()); - predictionClient = (predictionRow, contextRows, cols, idx) -> List.of(); + predictionClient = (predictionRow, contextRows, cols, keyNames) -> List.of(); cut.afterRead(ctx, dataList(row)); assertThat(row).doesNotContainKey("SAP_Recommendations"); }); @@ -393,7 +393,7 @@ private static Result twoContextRows() { private static RecommendationClient rptStyleClient() { Random random = new Random(42); - return (predictionRow, contextRows, predictionColumns, indexColumn) -> { + return (predictionRow, contextRows, predictionColumns, keyNames) -> { Map prediction = new HashMap<>(); for (String col : predictionColumns) { List available = @@ -401,24 +401,24 @@ private static RecommendationClient rptStyleClient() { Object val = available.isEmpty() ? null : available.get(random.nextInt(available.size())); prediction.put(col, List.of(Map.of("prediction", val))); } - prediction.put(indexColumn, predictionRow.get(indexColumn)); + prediction.put(keyNames.get(0), predictionRow.get(keyNames.get(0))); return List.of(CdsData.create(prediction)); }; } private static RecommendationClient randomPickClient() { Random random = new Random(42); - return (predictionRow, contextRows, predictionColumns, indexColumn) -> { + return (predictionRow, contextRows, predictionColumns, keyNames) -> { Map prediction = new HashMap<>(); for (String col : predictionColumns) { - if ("[PREDICT]".equals(predictionRow.get(col))) { + if (predictionRow.get(col) == null) { List available = contextRows.stream().filter(r -> r.get(col) != null).map(r -> r.get(col)).toList(); Object val = available.isEmpty() ? null : available.get(random.nextInt(available.size())); prediction.put(col, List.of(Map.of("prediction", val))); } } - prediction.put(indexColumn, predictionRow.get(indexColumn)); + prediction.put(keyNames.get(0), predictionRow.get(keyNames.get(0))); return List.of(CdsData.create(prediction)); }; } From 28046c7a884f749805632094992e3189d6c8dfdd Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Tue, 16 Jun 2026 13:37:03 +0200 Subject: [PATCH 05/34] Rename RptInferenceClient.api -> RptInferenceClient.rpt and extract code that creates sdkRow to separate method --- .../api/RptInferenceClient.java | 44 +++++++++---------- 1 file changed, 20 insertions(+), 24 deletions(-) diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java index 9639d8b..1e21d7d 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java @@ -52,10 +52,10 @@ public class RptInferenceClient implements RecommendationClient { private static final Retry INFERENCE_RETRY = buildInferenceRetry(); - private final DefaultApi api; + private final DefaultApi rpt; public RptInferenceClient(ApiClient apiClient) { - this.api = + this.rpt = new DefaultApi(apiClient.withObjectMapper(JacksonConfiguration.getDefaultObjectMapper())); } @@ -79,7 +79,7 @@ public List predict( return Retry.decorateSupplier( INFERENCE_RETRY, () -> { - var response = api.predict(request); + var response = rpt.predict(request); logger.debug("Prediction response id: {}", response.getId()); List> raw = JacksonConfiguration.getDefaultObjectMapper() @@ -140,27 +140,7 @@ private static PredictRequestPayload buildRequest( .taskType(TargetColumnConfig.TaskTypeEnum.CLASSIFICATION)) .toList(); - var sdkRows = - rows.stream() - .map( - row -> { - Map sdkRow = new HashMap<>(); - row.forEach( - (k, v) -> { - if (v != null - && !Drafts.ELEMENTS.contains(k) - && !MANAGED_FIELDS.contains(k)) { - sdkRow.put(k, RowsInnerValue.create(v.toString())); - } - }); - for (String target : predictionColumns) { - if (!row.containsKey(target) || row.get(target) == null) { - sdkRow.put(target, RowsInnerValue.create(PREDICT)); - } - } - return sdkRow; - }) - .toList(); + var sdkRows = rows.stream().map(row -> toSdkRow(row, predictionColumns)).toList(); return PredictRequestPayload.create() .predictionConfig(PredictionConfig.create().targetColumns(targetColumns)) @@ -168,6 +148,22 @@ private static PredictRequestPayload buildRequest( .indexColumn(indexColumn); } + private static Map toSdkRow(CdsData row, List predictionColumns) { + Map sdkRow = new HashMap<>(); + row.forEach( + (k, v) -> { + if (v != null && !Drafts.ELEMENTS.contains(k) && !MANAGED_FIELDS.contains(k)) { + sdkRow.put(k, RowsInnerValue.create(v.toString())); + } + }); + for (String target : predictionColumns) { + if (!row.containsKey(target) || row.get(target) == null) { + sdkRow.put(target, RowsInnerValue.create(PREDICT)); + } + } + return sdkRow; + } + private static Retry buildInferenceRetry() { RetryConfig config = RetryConfig.custom() From 9c1abbecab5d8176b885b63a882f608962d60dbf Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Tue, 16 Jun 2026 13:44:11 +0200 Subject: [PATCH 06/34] Moved everthing RPT-1 specific into RptInferenceClient --- .../cds/feature/recommendation/api/RptInferenceClient.java | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java index 1e21d7d..206fb16 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java @@ -117,11 +117,10 @@ private static void addSyntheticKeyIfNeeded( } } - // Returns a copy of the predictRow without the fields in Drafts.ELEMENTS and with a - // prediction placeholder for empty values in the predictinonColumns + // Returns a copy of the predictRow with a prediction placeholder replacing empty values + // in the predictionColumns - these will get filled by the predict method. private static CdsData preparePredictRow(CdsData predictRow, List predictionColumns) { Map preparedPredictRowMap = new HashMap<>(predictRow); - // Drafts.ELEMENTS.forEach(preparedPredictRowMap::remove); for (String col : predictionColumns) { preparedPredictRowMap.putIfAbsent(col, PREDICT); } From 59d11eafdfb7ca0ad9cfd3d0c7b54dc4d27988e6 Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Tue, 16 Jun 2026 14:38:41 +0200 Subject: [PATCH 07/34] Replace MANAGED_FIELDS set with annotation-driven exclusion: @Core.Computed, @readonly and remove logic from 'toSdkRow' method that was already executed elsewhere --- .../RecommendationContextBuilder.java | 17 +++++++++++++++-- .../recommendation/api/RptInferenceClient.java | 17 ++++------------- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationContextBuilder.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationContextBuilder.java index 8db5d24..2fa1da5 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationContextBuilder.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationContextBuilder.java @@ -17,6 +17,7 @@ import java.util.ArrayList; import java.util.EnumSet; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; @@ -31,6 +32,8 @@ class RecommendationContextBuilder { private static final String VALUE_LIST_ANNOTATION = "@Common.ValueList"; private static final String VALUE_LIST_WITH_FIXED_VALUES_ANNOTATION = "@Common.ValueListWithFixedValues"; + private static final String COMPUTED_ANNOTATION = "@Core.Computed"; + private static final String READONLY_ANNOTATION = "@readonly"; private static final Set SUPPORTED_CONTEXT_TYPES = EnumSet.of( CdsBaseType.STRING, @@ -111,12 +114,20 @@ CqnSelect buildContextQuery() { return select; } + // Builds the predict row from only the allowed columns (same set used in buildContextQuery), + // so draft, computed, and readonly fields are excluded by construction rather than explicit + // removal. CdsData buildPredictRow(CdsData row) { if (predictionElementNames.stream().noneMatch(c -> row.get(c) == null)) { return null; } - Map predictRow = new HashMap<>(row); - Drafts.ELEMENTS.forEach(predictRow::remove); + Set allowed = new HashSet<>(contextColumns); + allowed.addAll(keyNames); + Map predictRow = new HashMap<>(); + allowed.forEach( + col -> { + if (row.containsKey(col)) predictRow.put(col, row.get(col)); + }); return CdsData.create(predictRow); } @@ -138,6 +149,8 @@ private List computeContextColumns() { .filter( e -> SUPPORTED_CONTEXT_TYPES.contains(e.getType().as(CdsSimpleType.class).getType())) .filter(e -> !Drafts.ELEMENTS.contains(e.getName())) + .filter(byAnnotation(COMPUTED_ANNOTATION).negate()) + .filter(byAnnotation(READONLY_ANNOTATION).negate()) .map(CdsElement::getName) .toList(); } diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java index 206fb16..e71ee3a 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java @@ -12,7 +12,6 @@ import com.sap.ai.sdk.foundationmodels.rpt.generated.model.RowsInnerValue; import com.sap.ai.sdk.foundationmodels.rpt.generated.model.TargetColumnConfig; import com.sap.cds.CdsData; -import com.sap.cds.services.draft.Drafts; import com.sap.cloud.sdk.services.openapi.apache.apiclient.ApiClient; import com.sap.cloud.sdk.services.openapi.apache.core.OpenApiRequestException; import io.github.resilience4j.core.IntervalFunction; @@ -21,7 +20,6 @@ import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.Set; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -47,9 +45,6 @@ public class RptInferenceClient implements RecommendationClient { // RPT-1 specific: the placeholder value that marks a column as a prediction target in the request public static final String PREDICT = "[PREDICT]"; - private static final Set MANAGED_FIELDS = - Set.of("createdBy", "modifiedBy", "createdAt", "modifiedAt"); - private static final Retry INFERENCE_RETRY = buildInferenceRetry(); private final DefaultApi rpt; @@ -139,7 +134,7 @@ private static PredictRequestPayload buildRequest( .taskType(TargetColumnConfig.TaskTypeEnum.CLASSIFICATION)) .toList(); - var sdkRows = rows.stream().map(row -> toSdkRow(row, predictionColumns)).toList(); + var sdkRows = rows.stream().map(row -> toSdkRow(row)).toList(); return PredictRequestPayload.create() .predictionConfig(PredictionConfig.create().targetColumns(targetColumns)) @@ -147,19 +142,15 @@ private static PredictRequestPayload buildRequest( .indexColumn(indexColumn); } - private static Map toSdkRow(CdsData row, List predictionColumns) { + // Converts a CdsData row to the RPT SDK row format, i.e., into Map + private static Map toSdkRow(CdsData row) { Map sdkRow = new HashMap<>(); row.forEach( (k, v) -> { - if (v != null && !Drafts.ELEMENTS.contains(k) && !MANAGED_FIELDS.contains(k)) { + if (v != null) { sdkRow.put(k, RowsInnerValue.create(v.toString())); } }); - for (String target : predictionColumns) { - if (!row.containsKey(target) || row.get(target) == null) { - sdkRow.put(target, RowsInnerValue.create(PREDICT)); - } - } return sdkRow; } From e0f491ff97decba2728c4f34331253a23339332f Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Tue, 16 Jun 2026 14:44:46 +0200 Subject: [PATCH 08/34] Change level of log statement when no suitable context columns are found and recommendations are therefore skipped --- .../cds/feature/recommendation/FioriRecommendationHandler.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 8cb17ef..0fd05db 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 @@ -103,7 +103,7 @@ public void afterRead(CdsReadEventContext context, List dataList) { } if (builder.contextColumns().isEmpty()) { - logger.debug("No suitable context columns found, skipping predictions."); + logger.trace("No suitable context columns found, skipping predictions."); return; } From 7c2a8cb17201ea18e7785102b27772391eb14a7b Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Tue, 16 Jun 2026 14:52:55 +0200 Subject: [PATCH 09/34] Move 'return early if predictRow == null' to earlier in afterRead of FioriRecommendationHandler --- .../recommendation/FioriRecommendationHandler.java | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) 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 0fd05db..e6544eb 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 @@ -111,11 +111,6 @@ public void afterRead(CdsReadEventContext context, List dataList) { context .getServiceCatalog() .getService(PersistenceService.class, PersistenceService.DEFAULT_NAME); - List contextRows = new ArrayList<>(db.run(builder.buildContextQuery()).list()); - if (contextRows.size() < 2) { - logger.debug("Not enough context rows (minimum 2), skipping predictions."); - return; - } CdsData predictRow = builder.buildPredictRow(row); if (predictRow == null) { @@ -123,6 +118,12 @@ public void afterRead(CdsReadEventContext context, List dataList) { return; } + List contextRows = new ArrayList<>(db.run(builder.buildContextQuery()).list()); + if (contextRows.size() < 2) { + logger.debug("Not enough context rows (minimum 2), skipping predictions."); + return; + } + RecommendationClient client = clientResolver.resolve(aiCoreService); List predictions = client.predict( From d6c8f00f86ee97f7d4492927b230f2b40b8c5ea8 Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Tue, 16 Jun 2026 14:54:36 +0200 Subject: [PATCH 10/34] Extract 'SAP_Recommendations' into a constant --- .../cds/feature/recommendation/FioriRecommendationHandler.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 e6544eb..1cf1b0e 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 @@ -29,6 +29,7 @@ class FioriRecommendationHandler implements EventHandler { private static final Logger logger = LoggerFactory.getLogger(FioriRecommendationHandler.class); private static final int DEFAULT_CONTEXT_ROW_LIMIT = 2000; + private static final String SAP_RECOMMENDATIONS = "SAP_Recommendations"; private final AICoreService aiCoreService; private final RecommendationClientResolver clientResolver; @@ -143,6 +144,6 @@ public void afterRead(CdsReadEventContext context, List dataList) { Map recommendations = resultParser.buildRecommendations( db, predictions.get(0), missingPredictionElementNames, context, rowType); - row.put("SAP_Recommendations", recommendations); + row.put(SAP_RECOMMENDATIONS, recommendations); } } From e39677fb7f025d0d785b9547baac6e3b9f9aa146 Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Tue, 16 Jun 2026 14:56:06 +0200 Subject: [PATCH 11/34] Get Persistence Service db via Dependency Injection --- .../recommendation/FioriRecommendationHandler.java | 10 +++++----- .../recommendation/RecommendationConfiguration.java | 7 ++++++- .../recommendation/FioriRecommendationHandlerTest.java | 2 +- 3 files changed, 12 insertions(+), 7 deletions(-) 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 1cf1b0e..899430c 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 @@ -33,6 +33,7 @@ class FioriRecommendationHandler implements EventHandler { private final AICoreService aiCoreService; private final RecommendationClientResolver clientResolver; + private final PersistenceService db; private final RecommendationResultParser resultParser = new RecommendationResultParser(); // 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 @@ -41,9 +42,12 @@ class FioriRecommendationHandler implements EventHandler { Caffeine.newBuilder().maximumSize(10_000).build(); FioriRecommendationHandler( - AICoreService aiCoreService, RecommendationClientResolver clientResolver) { + AICoreService aiCoreService, + RecommendationClientResolver clientResolver, + PersistenceService db) { this.aiCoreService = aiCoreService; this.clientResolver = clientResolver; + this.db = db; } void invalidateTenant(String tenantId) { @@ -108,10 +112,6 @@ public void afterRead(CdsReadEventContext context, List dataList) { return; } - PersistenceService db = - context - .getServiceCatalog() - .getService(PersistenceService.class, PersistenceService.DEFAULT_NAME); CdsData predictRow = builder.buildPredictRow(row); if (predictRow == null) { 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 afde35c..9f66ead 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 @@ -9,6 +9,7 @@ import com.sap.cds.feature.recommendation.api.RptInferenceClient; import com.sap.cds.feature.recommendation.api.RptModelSpec; import com.sap.cds.services.ServiceCatalog; +import com.sap.cds.services.persistence.PersistenceService; import com.sap.cds.services.runtime.CdsRuntime; import com.sap.cds.services.runtime.CdsRuntimeConfiguration; import com.sap.cds.services.runtime.CdsRuntimeConfigurer; @@ -33,13 +34,17 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) { return; } + PersistenceService db = + serviceCatalog.getService(PersistenceService.class, PersistenceService.DEFAULT_NAME); + boolean hasBind = hasAICoreBinding(runtime); RecommendationClientResolver resolver = hasBind ? RecommendationConfiguration::resolveRptClient : service -> new MockRecommendationClient(); - FioriRecommendationHandler handler = new FioriRecommendationHandler(aiCoreService, resolver); + FioriRecommendationHandler handler = + new FioriRecommendationHandler(aiCoreService, resolver, db); configurer.eventHandler(handler); configurer.eventHandler(new RecommendationModelChangedHandler(handler)); } 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 efe4700..55f6787 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 @@ -67,7 +67,7 @@ void setup() { reset(db); when(db.getName()).thenReturn(PersistenceService.DEFAULT_NAME); predictionClient = randomPickClient(); - cut = new FioriRecommendationHandler(aiCoreService, (service) -> predictionClient); + cut = new FioriRecommendationHandler(aiCoreService, (service) -> predictionClient, db); } // ── tests ────────────────────────────────────────────────────────────────── From fc3926c31b10075c08ce4627aeb7434595aa8e8f Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Tue, 16 Jun 2026 14:59:12 +0200 Subject: [PATCH 12/34] Move missingPredictionElementNames to earlier in the afterRead of the FioriRecommendationHandler --- .../recommendation/FioriRecommendationHandler.java | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) 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 899430c..2949703 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 @@ -112,7 +112,6 @@ public void afterRead(CdsReadEventContext context, List dataList) { return; } - CdsData predictRow = builder.buildPredictRow(row); if (predictRow == null) { logger.debug("Current row already has values for all prediction columns, skipping."); @@ -125,10 +124,12 @@ public void afterRead(CdsReadEventContext context, List dataList) { return; } + List missingPredictionElementNames = + builder.predictionElementNames().stream().filter(c -> row.get(c) == null).toList(); + RecommendationClient client = clientResolver.resolve(aiCoreService); List predictions = - client.predict( - predictRow, contextRows, builder.predictionElementNames(), builder.keyNames()); + client.predict(predictRow, contextRows, missingPredictionElementNames, builder.keyNames()); if (predictions.isEmpty()) { logger.warn("No predictions returned from AI client."); @@ -139,8 +140,6 @@ public void afterRead(CdsReadEventContext context, List dataList) { return; } - List missingPredictionElementNames = - builder.predictionElementNames().stream().filter(c -> row.get(c) == null).toList(); Map recommendations = resultParser.buildRecommendations( db, predictions.get(0), missingPredictionElementNames, context, rowType); From f790bbfcb642da4e4ce77e065256d8c066bc78d1 Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Tue, 16 Jun 2026 15:12:12 +0200 Subject: [PATCH 13/34] Add comment about conversion from List to List --- .../cds/feature/recommendation/FioriRecommendationHandler.java | 1 + 1 file changed, 1 insertion(+) 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 2949703..36a76ee 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 @@ -118,6 +118,7 @@ public void afterRead(CdsReadEventContext context, List dataList) { return; } + // Result.list() returns List; the ArrayList copy also converts it to List. List contextRows = new ArrayList<>(db.run(builder.buildContextQuery()).list()); if (contextRows.size() < 2) { logger.debug("Not enough context rows (minimum 2), skipping predictions."); From 68bb6e1cdc33bdbbadca8ce1602479a63da14e85 Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Tue, 16 Jun 2026 15:15:13 +0200 Subject: [PATCH 14/34] Change check for active Entity: only return early if isActiveEntity is selected and false --- .../cds/feature/recommendation/FioriRecommendationHandler.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 36a76ee..d89bfaf 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 @@ -82,7 +82,8 @@ public void afterRead(CdsReadEventContext context, List dataList) { return; } - if (!Boolean.FALSE.equals(row.get(Drafts.IS_ACTIVE_ENTITY))) { + if (row.containsKey(Drafts.IS_ACTIVE_ENTITY) + && !Boolean.FALSE.equals(row.get(Drafts.IS_ACTIVE_ENTITY))) { return; } From b60c3c1961df9bb7f91c8abf9caf3d00cba9cf9b Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Tue, 16 Jun 2026 15:22:06 +0200 Subject: [PATCH 15/34] Add comments to MockRecommendationClient --- .../feature/recommendation/MockRecommendationClient.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/MockRecommendationClient.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/MockRecommendationClient.java index c4a4540..2a54ed7 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/MockRecommendationClient.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/MockRecommendationClient.java @@ -10,8 +10,13 @@ import java.util.Map; import java.util.Random; +// Mock implementation used when no AI Core binding is present. For each prediction column that +// is null in the predict row, it picks a random non-null value from the same column across the +// context rows and returns it as the prediction. Columns already filled are left unchanged. class MockRecommendationClient implements RecommendationClient { + // We use random here so you can see a difference in the UI. The actual value returned here is not + // relevant for tests. private final Random random = new Random(); @Override @@ -31,6 +36,8 @@ public List predict( ? null : availableValues.get(random.nextInt(availableValues.size())); Map predictionEntry = new HashMap<>(); + // Replace the empty entry in col with a randomly picked value of entries in the + // contextRows. predictionEntry.put("prediction", contextValue); prediction.put(col, List.of(predictionEntry)); } From bcf16def4070ce848c3b958bd79f09edb233bef2 Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Tue, 16 Jun 2026 15:35:44 +0200 Subject: [PATCH 16/34] Change type of slectcolumns to Set --- .../recommendation/RecommendationContextBuilder.java | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationContextBuilder.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationContextBuilder.java index 2fa1da5..637af6b 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationContextBuilder.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationContextBuilder.java @@ -14,7 +14,6 @@ import com.sap.cds.reflect.CdsSimpleType; import com.sap.cds.reflect.CdsStructuredType; import com.sap.cds.services.draft.Drafts; -import java.util.ArrayList; import java.util.EnumSet; import java.util.HashMap; import java.util.HashSet; @@ -90,12 +89,9 @@ List keyNames() { } CqnSelect buildContextQuery() { - List selectColumns = new ArrayList<>(contextColumns); - for (String key : keyNames) { - if (!selectColumns.contains(key)) { - selectColumns.add(key); - } - } + Set selectColumns = new HashSet<>(contextColumns); + selectColumns.addAll(keyNames); + var select = Select.from(target.getQualifiedName()) .columns(selectColumns.toArray(String[]::new)) From 3d40bec00152876d27d5eda4283da3b987641045 Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Tue, 16 Jun 2026 15:43:00 +0200 Subject: [PATCH 17/34] Add test: row for which we want to do predictions is automatically excluded in the select query returned by buildContextQuery --- .../RecommendationContextBuilder.java | 2 ++ .../FioriRecommendationHandlerTest.java | 17 +++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationContextBuilder.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationContextBuilder.java index 637af6b..308fcae 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationContextBuilder.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationContextBuilder.java @@ -97,6 +97,8 @@ CqnSelect buildContextQuery() { .columns(selectColumns.toArray(String[]::new)) .where( predictionElementNames.stream() + // the row for which we want to do predictions is automatically + // excluded by this isNotNull check .map(col -> CQL.get(col).isNotNull()) .collect(CQL.withAnd())) .limit(contextRowLimit); 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 55f6787..2476e33 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 @@ -203,6 +203,23 @@ void draftRow_withGenreAndCurrency_addsSapRecommendations() { }); } + @Test + void contextQuery_excludesPredictionRowByRequiringNonNullPredictionColumns() { + runIn( + () -> { + Map row = draftRow("genre_ID", null); + CdsReadEventContext ctx = readContext("test.Books", List.of(row)); + ArgumentCaptor selectCaptor = ArgumentCaptor.forClass(CqnSelect.class); + when(db.run(selectCaptor.capture())).thenReturn(twoContextRows()); + cut.afterRead(ctx, dataList(row)); + // The WHERE clause requires all prediction columns to be non-null, so the current row + // (which has genre_ID = null) is automatically excluded from the context. + String selectSql = selectCaptor.getAllValues().get(0).toString(); + assertThat(selectSql).contains("\"is not\",\"null\""); + assertThat(selectSql).contains("genre_ID"); + }); + } + @Test void blobAndVectorFields_areExcludedFromContextSelect() { runIn( From 5339f522b475de44fcd455c1fb73e6f6bb2fc455 Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Tue, 16 Jun 2026 15:44:55 +0200 Subject: [PATCH 18/34] Add comment about ordering in the select query returned by buildContextQuery --- .../feature/recommendation/RecommendationContextBuilder.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationContextBuilder.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationContextBuilder.java index 308fcae..ba5597a 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationContextBuilder.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationContextBuilder.java @@ -103,6 +103,8 @@ CqnSelect buildContextQuery() { .collect(CQL.withAnd())) .limit(contextRowLimit); target + // ensure there is some stable ordering of the contextRows, if possible order by + // "most recently changed" so the model gets the most up-to-date data .concreteNonAssociationElements() .filter(byAnnotation("cds.on.update")) .map(CdsElement::getName) From aad972db8acd2c64a7df128c3a975be46a8bfdc5 Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Tue, 16 Jun 2026 15:53:32 +0200 Subject: [PATCH 19/34] Add comment computeSyntheticKey method and add test --- .../api/RptInferenceClient.java | 5 +- .../api/RptInferenceClientTest.java | 48 +++++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/api/RptInferenceClientTest.java diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java index e71ee3a..b376c45 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java @@ -93,8 +93,9 @@ private static String resolveIndexColumn(List keyNames) { return (keyNames.size() == 1 && "ID".equals(keyNames.get(0))) ? "ID" : SYNTHETIC_INDEX_COLUMN; } - // Compute the synthetic key as a concatenated string from the keys listed in keyNames - private static String computeSyntheticKey(Map row, List keyNames) { + // '\0' is used as separator because it cannot appear in database string values + // (VARCHAR/NVARCHAR), so concatenation of any composite key values is guaranteed collision-free. + static String computeSyntheticKey(Map row, List keyNames) { StringBuilder sb = new StringBuilder(); for (int i = 0; i < keyNames.size(); i++) { if (i > 0) sb.append('\0'); diff --git a/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/api/RptInferenceClientTest.java b/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/api/RptInferenceClientTest.java new file mode 100644 index 0000000..8abce82 --- /dev/null +++ b/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/api/RptInferenceClientTest.java @@ -0,0 +1,48 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.recommendation.api; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class RptInferenceClientTest { + + @Test + void computeSyntheticKey_singleKey() { + String key = RptInferenceClient.computeSyntheticKey(Map.of("ID", "abc"), List.of("ID")); + assertThat(key).isEqualTo("ID" + '\0' + "abc"); + } + + @Test + void computeSyntheticKey_compositeKey() { + String key = + RptInferenceClient.computeSyntheticKey( + Map.of("order_ID", 1, "item_no", 10), List.of("order_ID", "item_no")); + assertThat(key).isEqualTo("order_ID" + '\0' + "1" + '\0' + "item_no" + '\0' + "10"); + } + + @Test + void computeSyntheticKey_noCollision_betweenDifferentCompositions() { + // "1" + "0" must not produce the same key as "10" + "" + String key1 = + RptInferenceClient.computeSyntheticKey( + Map.of("order_ID", "1", "item_no", "0"), List.of("order_ID", "item_no")); + String key2 = + RptInferenceClient.computeSyntheticKey( + Map.of("order_ID", "10", "item_no", ""), List.of("order_ID", "item_no")); + assertThat(key1).isNotEqualTo(key2); + } + + @Test + void computeSyntheticKey_nullValue_doesNotCrash() { + Map row = new java.util.HashMap<>(); + row.put("order_ID", 1); + row.put("item_no", null); + String key = RptInferenceClient.computeSyntheticKey(row, List.of("order_ID", "item_no")); + assertThat(key).isEqualTo("order_ID" + '\0' + "1" + '\0' + "item_no" + '\0'); + } +} From 183348590decd84b1347b6a96df65047740f066f Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Tue, 16 Jun 2026 16:26:02 +0200 Subject: [PATCH 20/34] Make sure we dont react on @cds.odata.valuelist : false and add a test for that --- .../RecommendationContextBuilder.java | 2 ++ .../FioriRecommendationHandlerTest.java | 32 +++++++++++++++++++ .../resources/model/recommendations-test.cds | 11 +++++++ 3 files changed, 45 insertions(+) diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationContextBuilder.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationContextBuilder.java index ba5597a..c3b9368 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationContextBuilder.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationContextBuilder.java @@ -31,6 +31,7 @@ class RecommendationContextBuilder { private static final String VALUE_LIST_ANNOTATION = "@Common.ValueList"; private static final String VALUE_LIST_WITH_FIXED_VALUES_ANNOTATION = "@Common.ValueListWithFixedValues"; + private static final String ODATA_VALUE_LIST_ANNOTATION = "cds.odata.valuelist"; private static final String COMPUTED_ANNOTATION = "@Core.Computed"; private static final String READONLY_ANNOTATION = "@readonly"; private static final Set SUPPORTED_CONTEXT_TYPES = @@ -138,6 +139,7 @@ private List computePredictionElements() { byAnnotation(VALUE_LIST_ANNOTATION) .or(byAnnotation(VALUE_LIST_WITH_FIXED_VALUES_ANNOTATION))) .filter(e -> !e.getType().isAssociation()) + .filter(e -> !Boolean.FALSE.equals(e.getAnnotationValue(ODATA_VALUE_LIST_ANNOTATION, null))) .map(CdsElement::getName) .toList(); } 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 2476e33..48a8c25 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 @@ -220,6 +220,38 @@ void contextQuery_excludesPredictionRowByRequiringNonNullPredictionColumns() { }); } + @Test + void cdsoDataValueListFalse_fieldIsExcludedFromPredictions() { + runIn( + () -> { + Map row = new HashMap<>(); + row.put("ID", "a009c640-434a-4542-ac68-51b400c880ec"); + row.put("IsActiveEntity", false); + row.put("genre_ID", null); + row.put("suppressed_ID", null); + CdsReadEventContext ctx = readContext("test.BooksWithDisabledValueList", List.of(row)); + when(db.run(any(CqnSelect.class))) + .thenReturn( + ResultBuilder.selectedRows( + new ArrayList<>( + List.of( + new HashMap<>( + Map.of("ID", "x1", "genre_ID", 1, "suppressed_ID", 10)), + new HashMap<>( + Map.of("ID", "x2", "genre_ID", 2, "suppressed_ID", 20))))) + .result(), + ResultBuilder.selectedRows(List.of()).result()); + cut.afterRead(ctx, dataList(row)); + // genre_ID has @Common.ValueListWithFixedValues → predicted + // suppressed_ID has @cds.odata.valuelist: false → excluded + assertThat(row).containsKey("SAP_Recommendations"); + @SuppressWarnings("unchecked") + Map recs = (Map) row.get("SAP_Recommendations"); + assertThat(recs).containsKey("genre_ID"); + assertThat(recs).doesNotContainKey("suppressed_ID"); + }); + } + @Test void blobAndVectorFields_areExcludedFromContextSelect() { runIn( diff --git a/cds-feature-recommendations/src/test/resources/model/recommendations-test.cds b/cds-feature-recommendations/src/test/resources/model/recommendations-test.cds index bfcd611..66cbe9a 100644 --- a/cds-feature-recommendations/src/test/resources/model/recommendations-test.cds +++ b/cds-feature-recommendations/src/test/resources/model/recommendations-test.cds @@ -46,6 +46,16 @@ entity PlainEntity { title : String; } +@odata.draft.enabled +entity BooksWithDisabledValueList { + key ID : UUID; + @Common.ValueListWithFixedValues + genre_ID : Integer; + @Common.ValueListWithFixedValues + @cds.odata.valuelist: false + suppressed_ID : Integer; +} + service TestService { entity Books as projection on test.Books; entity Genres as projection on test.Genres; @@ -53,4 +63,5 @@ service TestService { entity OrderItems as projection on test.OrderItems; entity IsbnBooks as projection on test.IsbnBooks; entity PlainEntity as projection on test.PlainEntity; + entity BooksWithDisabledValueList as projection on test.BooksWithDisabledValueList; } From ec4deea0a06ec0362aacb1c1ff6a922632e0146f Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Tue, 16 Jun 2026 16:32:06 +0200 Subject: [PATCH 21/34] Simplify filters in computeContextColumn --- .../feature/recommendation/RecommendationContextBuilder.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationContextBuilder.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationContextBuilder.java index c3b9368..4c335e2 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationContextBuilder.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationContextBuilder.java @@ -147,9 +147,10 @@ private List computePredictionElements() { private List computeContextColumns() { return rowType .concreteNonAssociationElements() - .filter(e -> e.getType().isSimple()) .filter( - e -> SUPPORTED_CONTEXT_TYPES.contains(e.getType().as(CdsSimpleType.class).getType())) + e -> + e.getType() instanceof CdsSimpleType st + && SUPPORTED_CONTEXT_TYPES.contains(st.getType())) .filter(e -> !Drafts.ELEMENTS.contains(e.getName())) .filter(byAnnotation(COMPUTED_ANNOTATION).negate()) .filter(byAnnotation(READONLY_ANNOTATION).negate()) From fa7e5104e811f7805cd5398a6f871d06cad83dac Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Tue, 16 Jun 2026 16:38:38 +0200 Subject: [PATCH 22/34] Use any single key as RPT-1 index column, not just fields called ID and add test --- .../api/RptInferenceClient.java | 5 ++-- .../api/RptInferenceClientTest.java | 23 +++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java index b376c45..5269daa 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java @@ -88,9 +88,10 @@ public List predict( // is computed by concatenating all key fields and injected into each row before sending. private static final String SYNTHETIC_INDEX_COLUMN = "SAP_RECOMMENDATIONS_ID"; - // If there is one key, use it directly and don't compute a synthetic key + // If there is one key, use it directly; for composite keys a synthetic string column is needed + // since RPT-1 requires a single string index column. private static String resolveIndexColumn(List keyNames) { - return (keyNames.size() == 1 && "ID".equals(keyNames.get(0))) ? "ID" : SYNTHETIC_INDEX_COLUMN; + return keyNames.size() == 1 ? keyNames.get(0) : SYNTHETIC_INDEX_COLUMN; } // '\0' is used as separator because it cannot appear in database string values diff --git a/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/api/RptInferenceClientTest.java b/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/api/RptInferenceClientTest.java index 8abce82..e57d231 100644 --- a/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/api/RptInferenceClientTest.java +++ b/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/api/RptInferenceClientTest.java @@ -5,12 +5,35 @@ import static org.assertj.core.api.Assertions.assertThat; +import java.lang.reflect.Method; import java.util.List; import java.util.Map; import org.junit.jupiter.api.Test; class RptInferenceClientTest { + private static String resolveIndexColumn(List keyNames) throws Exception { + Method m = RptInferenceClient.class.getDeclaredMethod("resolveIndexColumn", List.class); + m.setAccessible(true); + return (String) m.invoke(null, keyNames); + } + + @Test + void resolveIndexColumn_singleKey_usesItDirectly() throws Exception { + assertThat(resolveIndexColumn(List.of("isbn"))).isEqualTo("isbn"); + } + + @Test + void resolveIndexColumn_singleKeyNamedId_usesItDirectly() throws Exception { + assertThat(resolveIndexColumn(List.of("ID"))).isEqualTo("ID"); + } + + @Test + void resolveIndexColumn_compositeKey_usesSyntheticColumn() throws Exception { + assertThat(resolveIndexColumn(List.of("order_ID", "item_no"))) + .isEqualTo("SAP_RECOMMENDATIONS_ID"); + } + @Test void computeSyntheticKey_singleKey() { String key = RptInferenceClient.computeSyntheticKey(Map.of("ID", "abc"), List.of("ID")); From 1e51867c5001dd95800a97acd82142bb545dc7da Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Tue, 16 Jun 2026 16:41:21 +0200 Subject: [PATCH 23/34] Adjust sample to changes in cds-feature-recomendations --- .../handlers/AICoreShowcaseHandler.java | 60 +++++++++---------- 1 file changed, 27 insertions(+), 33 deletions(-) diff --git a/samples/bookshop/srv/src/main/java/customer/bookshop/handlers/AICoreShowcaseHandler.java b/samples/bookshop/srv/src/main/java/customer/bookshop/handlers/AICoreShowcaseHandler.java index ebaa19f..1cff6be 100644 --- a/samples/bookshop/srv/src/main/java/customer/bookshop/handlers/AICoreShowcaseHandler.java +++ b/samples/bookshop/srv/src/main/java/customer/bookshop/handlers/AICoreShowcaseHandler.java @@ -112,45 +112,39 @@ public void onCreateConfiguration(EventContext context) { public void onPredictCategory(EventContext context) { List> products = (List>) context.get("products"); - List rows = new ArrayList<>(); - rows.add( - CdsData.create( - Map.of("ID", "ctx-1", "name", "Laptop", "price", "999.99", "category", "Electronics"))); - rows.add( - CdsData.create( - Map.of("ID", "ctx-2", "name", "Mouse", "price", "29.99", "category", "Electronics"))); - rows.add( - CdsData.create( - Map.of("ID", "ctx-3", "name", "Shirt", "price", "49.99", "category", "Clothing"))); - rows.add( - CdsData.create( - Map.of("ID", "ctx-4", "name", "Novel", "price", "14.99", "category", "Books"))); - rows.add( - CdsData.create( - Map.of("ID", "ctx-5", "name", "Blender", "price", "89.99", "category", "Appliances"))); - - for (Map product : products) { - Map row = new HashMap<>(product); - row.put("category", "[PREDICT]"); - rows.add(CdsData.create(row)); - } + List contextRows = + List.of( + CdsData.create( + Map.of("ID", "ctx-1", "name", "Laptop", "price", "999.99", "category", "Electronics")), + CdsData.create( + Map.of("ID", "ctx-2", "name", "Mouse", "price", "29.99", "category", "Electronics")), + CdsData.create( + Map.of("ID", "ctx-3", "name", "Shirt", "price", "49.99", "category", "Clothing")), + CdsData.create( + Map.of("ID", "ctx-4", "name", "Novel", "price", "14.99", "category", "Books")), + CdsData.create( + Map.of( + "ID", "ctx-5", "name", "Blender", "price", "89.99", "category", "Appliances"))); AICoreService service = getAICoreService(); String rg = service.resourceGroup(); String deploymentId = service.deploymentId(rg, RptModelSpec.rpt1()); - RptInferenceClient client = - new RptInferenceClient(service.inferenceClient(rg, deploymentId)); - List predictions = client.predict(rows, List.of("category"), "ID"); + RptInferenceClient client = new RptInferenceClient(service.inferenceClient(rg, deploymentId)); List> results = new ArrayList<>(); - for (CdsData prediction : predictions) { - String id = (String) prediction.get("ID"); - Object categoryObj = prediction.get("category"); - String category = - categoryObj instanceof List list && !list.isEmpty() - ? extractPrediction(list) - : String.valueOf(categoryObj); - results.add(Map.of("ID", id, "category", category)); + for (Map product : products) { + CdsData predictionRow = CdsData.create(new HashMap<>(product)); + List predictions = + client.predict(predictionRow, contextRows, List.of("category"), List.of("ID")); + for (CdsData prediction : predictions) { + String id = (String) prediction.get("ID"); + Object categoryObj = prediction.get("category"); + String category = + categoryObj instanceof List list && !list.isEmpty() + ? extractPrediction(list) + : String.valueOf(categoryObj); + results.add(Map.of("ID", id, "category", category)); + } } context.put("result", results); From 793d144d8b3c8ff453bf49edef6b38547c99d3ba Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Tue, 16 Jun 2026 16:50:00 +0200 Subject: [PATCH 24/34] Add comment about books entity in test model --- .../src/test/resources/model/recommendations-test.cds | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cds-feature-recommendations/src/test/resources/model/recommendations-test.cds b/cds-feature-recommendations/src/test/resources/model/recommendations-test.cds index 66cbe9a..5c30916 100644 --- a/cds-feature-recommendations/src/test/resources/model/recommendations-test.cds +++ b/cds-feature-recommendations/src/test/resources/model/recommendations-test.cds @@ -1,5 +1,7 @@ namespace test; +// genre_ID and currency_code are declared as plain scalars here because this is a test model. +// In a real CDS model these would be generated foreign key columns from annotated associations. @odata.draft.enabled entity Books { key ID : UUID; From 23e644ca7ac1348ef6e432b109d5151594e0fc00 Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Tue, 16 Jun 2026 16:59:31 +0200 Subject: [PATCH 25/34] Update javadoc --- .../sap/cds/feature/recommendation/api/RptInferenceClient.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java index 5269daa..8e11a60 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java @@ -35,7 +35,7 @@ * String rg = service.resourceGroup(); * String deploymentId = service.deploymentId(rg, RptModelSpec.rpt1()); * RptInferenceClient client = new RptInferenceClient(service.inferenceClient(rg, deploymentId)); - * List predictions = client.predict(rows, List.of("targetColumn"), "ID"); + * List predictions = client.predict(predictionRow, contextRows, List.of("targetColumn"), List.of("ID")); * } */ public class RptInferenceClient implements RecommendationClient { From 45f45de8f4c4c0b431e98f6ceb09b6c83bb514f0 Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Tue, 16 Jun 2026 17:00:06 +0200 Subject: [PATCH 26/34] Do not register FioriRecommendationHandler if no PersistenceService is found --- .../feature/recommendation/RecommendationConfiguration.java | 6 ++++++ .../recommendation/RecommendationConfigurationTest.java | 4 ++++ 2 files changed, 10 insertions(+) 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 9f66ead..e31a497 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 @@ -37,6 +37,12 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) { PersistenceService db = serviceCatalog.getService(PersistenceService.class, PersistenceService.DEFAULT_NAME); + if (db == null) { + logger.info( + "No PersistenceService found, skipping Fiori recommendation handler registration."); + return; + } + boolean hasBind = hasAICoreBinding(runtime); RecommendationClientResolver resolver = hasBind diff --git a/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/RecommendationConfigurationTest.java b/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/RecommendationConfigurationTest.java index 69b5062..8b3ebce 100644 --- a/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/RecommendationConfigurationTest.java +++ b/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/RecommendationConfigurationTest.java @@ -8,6 +8,7 @@ import com.sap.cds.feature.aicore.api.AICoreService; import com.sap.cds.services.ServiceCatalog; import com.sap.cds.services.environment.CdsEnvironment; +import com.sap.cds.services.persistence.PersistenceService; import com.sap.cds.services.runtime.CdsRuntime; import com.sap.cds.services.runtime.CdsRuntimeConfigurer; import java.util.stream.Stream; @@ -24,6 +25,7 @@ class RecommendationConfigurationTest { @Mock private ServiceCatalog serviceCatalog; @Mock private CdsEnvironment environment; @Mock private AICoreService aiCoreService; + @Mock private PersistenceService persistenceService; @Test void aiCoreServiceFound_registersHandler() { @@ -33,6 +35,8 @@ void aiCoreServiceFound_registersHandler() { when(environment.getServiceBindings()).thenReturn(Stream.empty()); when(serviceCatalog.getService(AICoreService.class, AICoreService.DEFAULT_NAME)) .thenReturn(aiCoreService); + when(serviceCatalog.getService(PersistenceService.class, PersistenceService.DEFAULT_NAME)) + .thenReturn(persistenceService); new RecommendationConfiguration().eventHandlers(configurer); From fa4966eeb8cb084cfc0ced5c7ee3a82ccfc6997c Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Tue, 16 Jun 2026 17:12:51 +0200 Subject: [PATCH 27/34] In RptInferenceClient:resolveIndexColumn: fall back to synthetic index column for non-string single keys --- .../api/RptInferenceClient.java | 15 ++++++---- .../api/RptInferenceClientTest.java | 28 +++++++++++++------ 2 files changed, 30 insertions(+), 13 deletions(-) diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java index 8e11a60..6bb189f 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java @@ -60,7 +60,7 @@ public List predict( List contextRows, List predictionColumns, List keyNames) { - String indexColumn = resolveIndexColumn(keyNames); + String indexColumn = resolveIndexColumn(keyNames, predictionRow); CdsData preparedPredictRow = preparePredictRow(predictionRow, predictionColumns); List allRows = new java.util.ArrayList<>(contextRows); allRows.add(preparedPredictRow); @@ -88,10 +88,15 @@ public List predict( // is computed by concatenating all key fields and injected into each row before sending. private static final String SYNTHETIC_INDEX_COLUMN = "SAP_RECOMMENDATIONS_ID"; - // If there is one key, use it directly; for composite keys a synthetic string column is needed - // since RPT-1 requires a single string index column. - private static String resolveIndexColumn(List keyNames) { - return keyNames.size() == 1 ? keyNames.get(0) : SYNTHETIC_INDEX_COLUMN; + // If there is one string-typed key, use it directly; for composite keys or non-string keys a + // synthetic string column is needed since RPT-1 requires a single string index column. + // Non-string single keys fall back to synthetic rather than just converting to a string. + // RPT-1 may reject a column declared as the index if its values are not strings. + private static String resolveIndexColumn(List keyNames, CdsData sampleRow) { + if (keyNames.size() == 1 && sampleRow.get(keyNames.get(0)) instanceof String) { + return keyNames.get(0); + } + return SYNTHETIC_INDEX_COLUMN; } // '\0' is used as separator because it cannot appear in database string values diff --git a/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/api/RptInferenceClientTest.java b/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/api/RptInferenceClientTest.java index e57d231..5784e21 100644 --- a/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/api/RptInferenceClientTest.java +++ b/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/api/RptInferenceClientTest.java @@ -5,6 +5,7 @@ import static org.assertj.core.api.Assertions.assertThat; +import com.sap.cds.CdsData; import java.lang.reflect.Method; import java.util.List; import java.util.Map; @@ -12,25 +13,36 @@ class RptInferenceClientTest { - private static String resolveIndexColumn(List keyNames) throws Exception { - Method m = RptInferenceClient.class.getDeclaredMethod("resolveIndexColumn", List.class); + private static String resolveIndexColumn(List keyNames, CdsData sampleRow) + throws Exception { + Method m = + RptInferenceClient.class.getDeclaredMethod("resolveIndexColumn", List.class, CdsData.class); m.setAccessible(true); - return (String) m.invoke(null, keyNames); + return (String) m.invoke(null, keyNames, sampleRow); } @Test - void resolveIndexColumn_singleKey_usesItDirectly() throws Exception { - assertThat(resolveIndexColumn(List.of("isbn"))).isEqualTo("isbn"); + void resolveIndexColumn_singleStringKey_usesItDirectly() throws Exception { + CdsData row = CdsData.create(Map.of("isbn", "978-3-16")); + assertThat(resolveIndexColumn(List.of("isbn"), row)).isEqualTo("isbn"); } @Test - void resolveIndexColumn_singleKeyNamedId_usesItDirectly() throws Exception { - assertThat(resolveIndexColumn(List.of("ID"))).isEqualTo("ID"); + void resolveIndexColumn_singleUuidKey_usesItDirectly() throws Exception { + CdsData row = CdsData.create(Map.of("ID", "a009c640-434a-4542-ac68-51b400c880ec")); + assertThat(resolveIndexColumn(List.of("ID"), row)).isEqualTo("ID"); + } + + @Test + void resolveIndexColumn_singleIntegerKey_usesSyntheticColumn() throws Exception { + CdsData row = CdsData.create(Map.of("order_ID", 42)); + assertThat(resolveIndexColumn(List.of("order_ID"), row)).isEqualTo("SAP_RECOMMENDATIONS_ID"); } @Test void resolveIndexColumn_compositeKey_usesSyntheticColumn() throws Exception { - assertThat(resolveIndexColumn(List.of("order_ID", "item_no"))) + CdsData row = CdsData.create(Map.of("order_ID", 1, "item_no", 10)); + assertThat(resolveIndexColumn(List.of("order_ID", "item_no"), row)) .isEqualTo("SAP_RECOMMENDATIONS_ID"); } From 1cfc6cdea49d6df233cda2994c9c669cec0ee0cb Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Tue, 16 Jun 2026 17:16:05 +0200 Subject: [PATCH 28/34] Add checks for the presence of keyNames --- .../feature/recommendation/FioriRecommendationHandler.java | 5 +++++ .../cds/feature/recommendation/MockRecommendationClient.java | 4 +++- 2 files changed, 8 insertions(+), 1 deletion(-) 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 d89bfaf..7a539ff 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 @@ -108,6 +108,11 @@ public void afterRead(CdsReadEventContext context, List dataList) { return; } + if (builder.keyNames().isEmpty()) { + logger.debug("Entity has no key elements, skipping predictions."); + return; + } + if (builder.contextColumns().isEmpty()) { logger.trace("No suitable context columns found, skipping predictions."); return; diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/MockRecommendationClient.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/MockRecommendationClient.java index 2a54ed7..cb2b64c 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/MockRecommendationClient.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/MockRecommendationClient.java @@ -42,7 +42,9 @@ public List predict( prediction.put(col, List.of(predictionEntry)); } } - prediction.put(indexColumn, predictionRow.get(keyNames.get(0))); + if (!keyNames.isEmpty()) { + prediction.put(indexColumn, predictionRow.get(keyNames.get(0))); + } return List.of(CdsData.create(prediction)); } } From 93586d5e45e1f8699c865e432cec1b8738b9a166 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Wed, 17 Jun 2026 13:55:41 +0200 Subject: [PATCH 29/34] remove aicoreservice interface and impl and introduce constants --- .../sap/cds/feature/aicore/api/AICore.java | 32 ++++++ .../cds/feature/aicore/api/AICoreService.java | 88 ----------------- .../aicore/api/DeploymentIdContext.java | 6 +- .../aicore/api/InferenceClientContext.java | 4 +- .../aicore/api/ModelDeploymentSpec.java | 4 +- .../aicore/api/ResourceGroupContext.java | 6 +- .../core/AICoreServiceConfiguration.java | 97 +++++++++++-------- .../aicore/core/AICoreServiceImpl.java | 70 ------------- .../aicore/core/handler/AICoreApiHandler.java | 6 +- .../aicore/core/handler/ActionHandler.java | 4 +- .../core/handler/ConfigurationHandler.java | 4 +- .../core/handler/DeploymentHandler.java | 4 +- .../core/handler/MockAICoreApiHandler.java | 8 +- .../core/handler/MockEntityHandler.java | 24 ++--- .../core/handler/ResourceGroupHandler.java | 4 +- 15 files changed, 124 insertions(+), 237 deletions(-) create mode 100644 cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/AICore.java delete mode 100644 cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/AICoreService.java delete mode 100644 cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceImpl.java diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/AICore.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/AICore.java new file mode 100644 index 0000000..764bb18 --- /dev/null +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/AICore.java @@ -0,0 +1,32 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.aicore.api; + +/** + * Constants for the AI Core plugin. + * + *

Use {@link #SERVICE_NAME} for service catalog lookups and {@code @ServiceName} annotations. + * Use the entity constants for {@code @On(entity = ...)} handler annotations. + * + *

The service is a {@link com.sap.cds.services.cds.RemoteService RemoteService} auto-created by + * the CAP Java runtime from the CDS model. Callers interact with it by emitting typed {@link + * com.sap.cds.services.EventContext EventContext} instances ({@link ResourceGroupContext}, {@link + * DeploymentIdContext}, {@link InferenceClientContext}) or via CQL on the defined entities. + */ +public final class AICore { + + private AICore() {} + + /** Service name matching the CDS service definition, used for catalog lookup. */ + public static final String SERVICE_NAME = "AICore"; + + /** Qualified name of the {@code resourceGroups} entity. */ + public static final String RESOURCE_GROUPS = "AICore.resourceGroups"; + + /** Qualified name of the {@code deployments} entity. */ + public static final String DEPLOYMENTS = "AICore.deployments"; + + /** Qualified name of the {@code configurations} entity. */ + public static final String CONFIGURATIONS = "AICore.configurations"; +} diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/AICoreService.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/AICoreService.java deleted file mode 100644 index 8a2e24c..0000000 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/AICoreService.java +++ /dev/null @@ -1,88 +0,0 @@ -/* - * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. - */ -package com.sap.cds.feature.aicore.api; - -import com.sap.cds.services.cds.RemoteService; -import com.sap.cloud.sdk.services.openapi.apache.apiclient.ApiClient; - -/** - * CAP service contract for SAP AI Core integration. - * - *

The service exposes resource-group, configuration and deployment lifecycle as CDS entities - * (see {@link #RESOURCE_GROUPS}, {@link #DEPLOYMENTS}, {@link #CONFIGURATIONS}) and additionally - * provides programmatic helpers to: - * - *

    - *
  • Resolve the resource group ID for the current tenant ({@link #resourceGroup()}), creating - * it on-demand when multi-tenancy is enabled. - *
  • Resolve (or create) a deployment matching a {@link ModelDeploymentSpec} ({@link - * #deploymentId(String, ModelDeploymentSpec)}). - *
  • Build an {@link ApiClient} preconfigured for inference against a specific deployment - * ({@link #inferenceClient(String, String)}). - *
- * - *

The implementation is tenant-aware: it reads the current tenant from the {@code - * RequestContext}. Callers do not need to pass tenant identifiers explicitly. - */ -public interface AICoreService extends RemoteService { - - /** Default service name under which an instance is registered in the service catalog. */ - String DEFAULT_NAME = "AICoreService$Default"; - - /** Qualified name of the {@code resourceGroups} entity exposed by this service. */ - String RESOURCE_GROUPS = "AICore.resourceGroups"; - - /** Qualified name of the {@code deployments} entity exposed by this service. */ - String DEPLOYMENTS = "AICore.deployments"; - - /** Qualified name of the {@code configurations} entity exposed by this service. */ - String CONFIGURATIONS = "AICore.configurations"; - - /** - * Returns the AI Core resource group ID associated with the current tenant. - * - *

When multi-tenancy is disabled the configured default resource group is returned. When - * enabled, the resource group is looked up by the {@code ext.ai.sap.com/CDS_TENANT_ID} label and - * created on first call if it does not exist. - * - * @return the AI Core resource group ID for the current tenant - */ - String resourceGroup(); - - /** - * Returns the AI Core resource group ID associated with the given tenant. - * - *

This variant is used during subscribe/unsubscribe flows where the tenant ID is explicitly - * available from the context rather than the current request. - * - * @param tenantId the CDS tenant identifier - * @return the AI Core resource group ID for the specified tenant - */ - String resourceGroupForTenant(String tenantId); - - /** - * Returns the deployment ID for the given model spec inside the given resource group. - * - *

Looks up an existing RUNNING/PENDING deployment that matches the spec, otherwise creates a - * configuration (if missing) and a new deployment, then polls until the deployment reaches - * RUNNING. Results are cached per {@code (resourceGroupId, configurationName)} pair. - * - * @param resourceGroupId the AI Core resource group to operate in - * @param spec the deployment specification (scenario, executable, configuration name and - * existing-match predicate) - * @return the deployment ID - */ - String deploymentId(String resourceGroupId, ModelDeploymentSpec spec); - - /** - * Returns an {@link ApiClient} preconfigured with the inference destination for the given - * deployment, suitable for constructing foundation-model SDK clients. - * - * @param resourceGroupId the AI Core resource group containing the deployment - * @param deploymentId the deployment ID returned by {@link #deploymentId(String, - * ModelDeploymentSpec)} - * @return a configured {@link ApiClient} pointing at the deployment's inference endpoint - */ - ApiClient inferenceClient(String resourceGroupId, String deploymentId); -} diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/DeploymentIdContext.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/DeploymentIdContext.java index 4fa04d9..04857c0 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/DeploymentIdContext.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/DeploymentIdContext.java @@ -9,9 +9,9 @@ /** * Typed {@link EventContext} for the {@code deploymentId} event. * - *

Emitted by {@link AICoreService#deploymentId(String, ModelDeploymentSpec)} to resolve (or - * create) a deployment matching the given spec inside the given resource group. The ON handler - * performs cache lookup, retry, configuration creation, deployment creation and polling. + *

Emitted on the AI Core service to resolve (or create) a deployment matching the given spec + * inside the given resource group. The ON handler performs cache lookup, retry, configuration + * creation, deployment creation and polling. */ @EventName(DeploymentIdContext.EVENT) public interface DeploymentIdContext extends EventContext { diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/InferenceClientContext.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/InferenceClientContext.java index 1b3095e..8ed8710 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/InferenceClientContext.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/InferenceClientContext.java @@ -10,8 +10,8 @@ /** * Typed {@link EventContext} for the {@code inferenceClient} event. * - *

Emitted by {@link AICoreService#inferenceClient(String, String)} to build an {@link ApiClient} - * preconfigured with the inference destination for the given deployment. + *

Emitted on the AI Core service to build an {@link ApiClient} preconfigured with the inference + * destination for the given deployment. */ @EventName(InferenceClientContext.EVENT) public interface InferenceClientContext extends EventContext { diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/ModelDeploymentSpec.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/ModelDeploymentSpec.java index a51d1b6..72b29f3 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/ModelDeploymentSpec.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/ModelDeploymentSpec.java @@ -9,8 +9,8 @@ import java.util.function.Predicate; /** - * Describes a target AI Core deployment used by {@link AICoreService#deploymentId(String, - * ModelDeploymentSpec)} to look up or create a deployment inside a resource group. + * Describes a target AI Core deployment used by the {@code deploymentId} event to look up or create + * a deployment inside a resource group. * *

The spec carries the AI Core scenario/executable identification, the human-readable * configuration name (used as a stable key for caching and idempotent reuse), the parameter diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/ResourceGroupContext.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/ResourceGroupContext.java index 19a2a82..9d08328 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/ResourceGroupContext.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/ResourceGroupContext.java @@ -9,9 +9,9 @@ /** * Typed {@link EventContext} for the {@code resourceGroup} event. * - *

Emitted by {@link AICoreService#resourceGroup()} to resolve the AI Core resource group ID for - * the current tenant. In multi-tenancy mode, the resource group is created on-demand if it does not - * exist. In single-tenancy mode, the configured default resource group is returned. + *

Emitted on the AI Core service to resolve the AI Core resource group ID for the current + * tenant. In multi-tenancy mode, the resource group is created on-demand if it does not exist. In + * single-tenancy mode, the configured default resource group is returned. * *

If {@link #getTenantId()} is non-null, the handler uses the explicit tenant ID. Otherwise, the * current tenant is read from the {@code RequestContext}. diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceConfiguration.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceConfiguration.java index 202ed8d..976cd24 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceConfiguration.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceConfiguration.java @@ -7,7 +7,7 @@ import com.sap.ai.sdk.core.client.ConfigurationApi; import com.sap.ai.sdk.core.client.DeploymentApi; import com.sap.ai.sdk.core.client.ResourceGroupApi; -import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.api.AICore; import com.sap.cds.feature.aicore.core.handler.AICoreApiHandler; import com.sap.cds.feature.aicore.core.handler.AICoreSetupHandler; import com.sap.cds.feature.aicore.core.handler.ActionHandler; @@ -18,6 +18,7 @@ import com.sap.cds.feature.aicore.core.handler.MockEntityHandler; import com.sap.cds.feature.aicore.core.handler.ResourceGroupHandler; import com.sap.cds.services.environment.CdsProperties; +import com.sap.cds.services.environment.CdsProperties.Remote.RemoteServiceConfig; import com.sap.cds.services.mt.DeploymentService; import com.sap.cds.services.runtime.CdsRuntime; import com.sap.cds.services.runtime.CdsRuntimeConfiguration; @@ -27,13 +28,16 @@ import org.slf4j.LoggerFactory; /** - * {@link CdsRuntimeConfiguration} that wires the {@code AICore} service and its event handlers into - * the CAP Java runtime. + * {@link CdsRuntimeConfiguration} that wires the {@code AICore} remote service and its event + * handlers into the CAP Java runtime. * - *

Detects the presence of an SAP AI Core service binding (either a regular service binding or - * the {@code AICORE_SERVICE_KEY} environment variable used for hybrid local testing) and registers - * the appropriate handlers. Picked up automatically through {@code ServiceLoader}; applications do - * not need to instantiate this class directly. + *

In the {@link #environment} phase, a {@link RemoteServiceConfig} entry for "AICore" is + * injected into the runtime properties so the framework's {@code RemoteServiceConfiguration} + * auto-creates the service instance from the CDS model. This follows the same pattern used by + * {@code cds-feature-notifications}. + * + *

In the {@link #eventHandlers} phase, production or mock handlers are registered depending on + * whether an AI Core service binding is present. */ public class AICoreServiceConfiguration implements CdsRuntimeConfiguration { @@ -43,34 +47,23 @@ public class AICoreServiceConfiguration implements CdsRuntimeConfiguration { private AICoreClients clients; private DeploymentResolver resolver; - private static boolean hasAICoreModel(CdsRuntime runtime) { - return runtime.getCdsModel().findService("AICore").isPresent(); - } - - private static boolean hasAICoreBinding(CdsRuntime runtime) { - return runtime - .getEnvironment() - .getServiceBindings() - .filter(b -> ServiceBindingUtils.matches(b, "aicore")) - .findFirst() - .isPresent(); - } - /** - * Detects multi-tenancy by checking the standard CAP Java {@code cds.multiTenancy.sidecar.url} - * property or the presence of a {@link DeploymentService} in the service catalog. This aligns - * with the standard CAP Java convention — no custom property flag is needed. + * Injects a {@link RemoteServiceConfig} for "AICore" into the runtime properties. This runs + * before all {@code services()} methods, ensuring the framework's {@code + * RemoteServiceConfiguration} will auto-create a {@code RemoteServiceImpl} for the AICore CDS + * service definition. */ - private static boolean detectMultiTenancy(CdsRuntime runtime) { - CdsProperties props = runtime.getEnvironment().getCdsProperties(); - String sidecarUrl = props.getMultiTenancy().getSidecar().getUrl(); - if (sidecarUrl != null && !sidecarUrl.isBlank()) { - return true; - } - return runtime - .getServiceCatalog() - .getService(DeploymentService.class, DeploymentService.DEFAULT_NAME) - != null; + @Override + public void environment(CdsRuntimeConfigurer configurer) { + RemoteServiceConfig remoteConfig = new RemoteServiceConfig(AICore.SERVICE_NAME); + remoteConfig.setModel(AICore.SERVICE_NAME); + configurer + .getCdsRuntime() + .getEnvironment() + .getCdsProperties() + .getRemote() + .getServices() + .putIfAbsent(AICore.SERVICE_NAME, remoteConfig); } @Override @@ -78,7 +71,7 @@ public void services(CdsRuntimeConfigurer configurer) { CdsRuntime runtime = configurer.getCdsRuntime(); if (!hasAICoreModel(runtime)) { - logger.debug("AICore CDS model not found in runtime model — skipping service registration."); + logger.debug("AICore CDS model not found in runtime model - skipping handler setup."); return; } @@ -96,23 +89,19 @@ public void services(CdsRuntimeConfigurer configurer) { this.clients = new AICoreClients(deploymentApi, configurationApi, resourceGroupApi, sdkService); this.resolver = new DeploymentResolver(config, deploymentApi, resourceGroupApi); - logger.info("Registered AICoreService backed by AI Core binding."); + logger.info("AI Core binding detected - production handlers will be registered."); } else { - logger.info( - "Registered AICoreService (no AI Core binding found — mock handlers will be used)."); + logger.info("No AI Core binding found - mock handlers will be registered."); } - - configurer.service(new AICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime)); } @Override public void eventHandlers(CdsRuntimeConfigurer configurer) { if (config == null) { - return; // No AICore model — services() skipped registration + return; // No AICore model - services() skipped } if (clients != null) { - // Production path: real AI Core binding configurer.eventHandler(new AICoreApiHandler(config, clients, resolver)); configurer.eventHandler(new ResourceGroupHandler(config, clients, resolver)); configurer.eventHandler(new DeploymentHandler(config, clients, resolver)); @@ -125,7 +114,6 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) { logger.debug("Registered AI Core setup handler for MTX subscribe/unsubscribe."); } } else { - // Mock path: no AI Core binding MockAICoreApiHandler mockApiHandler = new MockAICoreApiHandler(config); configurer.eventHandler(new MockEntityHandler()); configurer.eventHandler(mockApiHandler); @@ -137,4 +125,29 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) { } } } + + private static boolean hasAICoreModel(CdsRuntime runtime) { + return runtime.getCdsModel().findService(AICore.SERVICE_NAME).isPresent(); + } + + private static boolean hasAICoreBinding(CdsRuntime runtime) { + return runtime + .getEnvironment() + .getServiceBindings() + .filter(b -> ServiceBindingUtils.matches(b, "aicore")) + .findFirst() + .isPresent(); + } + + private static boolean detectMultiTenancy(CdsRuntime runtime) { + CdsProperties props = runtime.getEnvironment().getCdsProperties(); + String sidecarUrl = props.getMultiTenancy().getSidecar().getUrl(); + if (sidecarUrl != null && !sidecarUrl.isBlank()) { + return true; + } + return runtime + .getServiceCatalog() + .getService(DeploymentService.class, DeploymentService.DEFAULT_NAME) + != null; + } } diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceImpl.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceImpl.java deleted file mode 100644 index b1c226c..0000000 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceImpl.java +++ /dev/null @@ -1,70 +0,0 @@ -/* - * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. - */ -package com.sap.cds.feature.aicore.core; - -import com.sap.cds.feature.aicore.api.AICoreService; -import com.sap.cds.feature.aicore.api.DeploymentIdContext; -import com.sap.cds.feature.aicore.api.InferenceClientContext; -import com.sap.cds.feature.aicore.api.ModelDeploymentSpec; -import com.sap.cds.feature.aicore.api.ResourceGroupContext; -import com.sap.cds.services.impl.cds.AbstractCdsDefinedService; -import com.sap.cds.services.runtime.CdsRuntime; -import com.sap.cloud.sdk.services.openapi.apache.apiclient.ApiClient; - -/** - * Production implementation of {@link AICoreService}. - * - *

This class is a pure delegation layer: each API method creates a typed {@link - * com.sap.cds.services.EventContext EventContext}, emits it via the CAP event mechanism, and - * returns the handler's result. All business logic (caching, locking, API calls) lives in the - * registered ON handlers which receive their dependencies via constructor injection. - * - *

Implementation note: This class extends {@code AbstractCdsDefinedService} - * from the CAP Java runtime's internal {@code impl} package. This is necessary because the public - * API ({@code ServiceDelegator}) does not provide CQN execution capabilities or CDS model binding. - * The semi-public {@code AbstractCqnService} (from {@code cds-services-utils}) provides CQN but not - * {@code getDefinition()}. Until a public API alternative exists, this coupling is accepted and - * version-compatibility is verified through integration tests against the CAP Java runtime. - */ -public class AICoreServiceImpl extends AbstractCdsDefinedService implements AICoreService { - - private static final String CDS_DEFINITION_NAME = "AICore"; - - public AICoreServiceImpl(String name, CdsRuntime runtime) { - super(name, CDS_DEFINITION_NAME, runtime); - } - - @Override - public String resourceGroup() { - ResourceGroupContext ctx = ResourceGroupContext.create(); - emit(ctx); - return ctx.getResult(); - } - - @Override - public String resourceGroupForTenant(String tenantId) { - ResourceGroupContext ctx = ResourceGroupContext.create(); - ctx.setTenantId(tenantId); - emit(ctx); - return ctx.getResult(); - } - - @Override - public String deploymentId(String resourceGroupId, ModelDeploymentSpec spec) { - DeploymentIdContext ctx = DeploymentIdContext.create(); - ctx.setResourceGroupId(resourceGroupId); - ctx.setSpec(spec); - emit(ctx); - return ctx.getResult(); - } - - @Override - public ApiClient inferenceClient(String resourceGroupId, String deploymentId) { - InferenceClientContext ctx = InferenceClientContext.create(); - ctx.setResourceGroupId(resourceGroupId); - ctx.setDeploymentId(deploymentId); - emit(ctx); - return ctx.getResult(); - } -} diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AICoreApiHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AICoreApiHandler.java index 3ecf8e7..613cdd1 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AICoreApiHandler.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AICoreApiHandler.java @@ -10,7 +10,7 @@ import com.sap.ai.sdk.core.model.AiDeploymentList; import com.sap.ai.sdk.core.model.AiDeploymentResponseWithDetails; import com.sap.ai.sdk.core.model.AiDeploymentStatus; -import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.api.AICore; import com.sap.cds.feature.aicore.api.DeploymentIdContext; import com.sap.cds.feature.aicore.api.InferenceClientContext; import com.sap.cds.feature.aicore.api.ModelDeploymentSpec; @@ -32,13 +32,13 @@ import org.slf4j.LoggerFactory; /** - * ON handler for the {@link AICoreService} API events ({@code resourceGroup}, {@code deploymentId}, + * ON handler for the AI Core service API events ({@code resourceGroup}, {@code deploymentId}, * {@code inferenceClient}). * *

Contains the business logic for deployment discovery/creation and inference client * construction. Resource-group resolution is delegated to {@link DeploymentResolver}. */ -@ServiceName(AICoreService.DEFAULT_NAME) +@ServiceName(AICore.SERVICE_NAME) public class AICoreApiHandler implements EventHandler { private static final Logger logger = LoggerFactory.getLogger(AICoreApiHandler.class); diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ActionHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ActionHandler.java index 2c9d1e7..8fb95f5 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ActionHandler.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ActionHandler.java @@ -5,7 +5,7 @@ import com.sap.ai.sdk.core.model.AiDeploymentModificationRequest; import com.sap.ai.sdk.core.model.AiDeploymentTargetStatus; -import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.api.AICore; import com.sap.cds.feature.aicore.core.AICoreClients; import com.sap.cds.feature.aicore.core.AICoreConfig; import com.sap.cds.feature.aicore.core.DeploymentResolver; @@ -19,7 +19,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -@ServiceName(AICoreService.DEFAULT_NAME) +@ServiceName(AICore.SERVICE_NAME) public class ActionHandler extends AbstractCrudHandler { private static final Logger logger = LoggerFactory.getLogger(ActionHandler.class); diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandler.java index bf4e1d8..76c6642 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandler.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandler.java @@ -7,7 +7,7 @@ import com.sap.ai.sdk.core.model.AiConfigurationBaseData; import com.sap.ai.sdk.core.model.AiConfigurationList; import com.sap.ai.sdk.core.model.AiParameterArgumentBinding; -import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.api.AICore; import com.sap.cds.feature.aicore.core.AICoreClients; import com.sap.cds.feature.aicore.core.AICoreConfig; import com.sap.cds.feature.aicore.core.DeploymentResolver; @@ -32,7 +32,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -@ServiceName(AICoreService.DEFAULT_NAME) +@ServiceName(AICore.SERVICE_NAME) public class ConfigurationHandler extends AbstractCrudHandler { private static final Logger logger = LoggerFactory.getLogger(ConfigurationHandler.class); diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandler.java index 4657bc4..193a5ae 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandler.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandler.java @@ -9,7 +9,7 @@ import com.sap.ai.sdk.core.model.AiDeploymentModificationRequest; import com.sap.ai.sdk.core.model.AiDeploymentResponseWithDetails; import com.sap.ai.sdk.core.model.AiDeploymentTargetStatus; -import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.api.AICore; import com.sap.cds.feature.aicore.core.AICoreClients; import com.sap.cds.feature.aicore.core.AICoreConfig; import com.sap.cds.feature.aicore.core.DeploymentResolver; @@ -36,7 +36,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -@ServiceName(AICoreService.DEFAULT_NAME) +@ServiceName(AICore.SERVICE_NAME) public class DeploymentHandler extends AbstractCrudHandler { private static final Logger logger = LoggerFactory.getLogger(DeploymentHandler.class); diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/MockAICoreApiHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/MockAICoreApiHandler.java index f5155a2..133ebd1 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/MockAICoreApiHandler.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/MockAICoreApiHandler.java @@ -3,7 +3,7 @@ */ package com.sap.cds.feature.aicore.core.handler; -import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.api.AICore; import com.sap.cds.feature.aicore.api.DeploymentIdContext; import com.sap.cds.feature.aicore.api.InferenceClientContext; import com.sap.cds.feature.aicore.api.ModelDeploymentSpec; @@ -20,10 +20,10 @@ import org.slf4j.LoggerFactory; /** - * Mock ON handler for the {@link AICoreService} API events when no AI Core binding is available. - * Uses in-memory maps instead of real API calls. + * Mock ON handler for the AI Core service API events when no AI Core binding is available. Uses + * in-memory maps instead of real API calls. */ -@ServiceName(AICoreService.DEFAULT_NAME) +@ServiceName(AICore.SERVICE_NAME) public class MockAICoreApiHandler implements EventHandler { private static final Logger logger = LoggerFactory.getLogger(MockAICoreApiHandler.class); diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/MockEntityHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/MockEntityHandler.java index 2ac76ab..4680b7a 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/MockEntityHandler.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/MockEntityHandler.java @@ -4,7 +4,7 @@ package com.sap.cds.feature.aicore.core.handler; import com.sap.cds.CdsData; -import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.api.AICore; import com.sap.cds.ql.cqn.AnalysisResult; import com.sap.cds.ql.cqn.CqnAnalyzer; import com.sap.cds.ql.cqn.CqnDelete; @@ -25,7 +25,7 @@ import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; -@ServiceName(AICoreService.DEFAULT_NAME) +@ServiceName(AICore.SERVICE_NAME) public class MockEntityHandler implements EventHandler { private final Map> resourceGroups = new ConcurrentHashMap<>(); @@ -34,7 +34,7 @@ public class MockEntityHandler implements EventHandler { // --- Resource Groups --- - @On(entity = AICoreService.RESOURCE_GROUPS) + @On(entity = AICore.RESOURCE_GROUPS) public void readResourceGroups(CdsReadEventContext context) { CqnSelect select = context.getCqn(); CdsModel model = context.getModel(); @@ -61,7 +61,7 @@ public void readResourceGroups(CdsReadEventContext context) { } } - @On(entity = AICoreService.RESOURCE_GROUPS) + @On(entity = AICore.RESOURCE_GROUPS) public void createResourceGroups(CdsCreateEventContext context) { CqnInsert insert = context.getCqn(); List> results = new ArrayList<>(); @@ -76,7 +76,7 @@ public void createResourceGroups(CdsCreateEventContext context) { context.setResult(results); } - @On(entity = AICoreService.RESOURCE_GROUPS) + @On(entity = AICore.RESOURCE_GROUPS) public void updateResourceGroups(CdsUpdateEventContext context) { CqnUpdate update = context.getCqn(); CdsModel model = context.getModel(); @@ -92,7 +92,7 @@ public void updateResourceGroups(CdsUpdateEventContext context) { context.setResult(List.of(CdsData.create(existing))); } - @On(entity = AICoreService.RESOURCE_GROUPS) + @On(entity = AICore.RESOURCE_GROUPS) public void deleteResourceGroups(CdsDeleteEventContext context) { CqnDelete delete = context.getCqn(); CdsModel model = context.getModel(); @@ -105,7 +105,7 @@ public void deleteResourceGroups(CdsDeleteEventContext context) { // --- Deployments --- - @On(entity = AICoreService.DEPLOYMENTS) + @On(entity = AICore.DEPLOYMENTS) public void readDeployments(CdsReadEventContext context) { CqnSelect select = context.getCqn(); CdsModel model = context.getModel(); @@ -121,7 +121,7 @@ public void readDeployments(CdsReadEventContext context) { } } - @On(entity = AICoreService.DEPLOYMENTS) + @On(entity = AICore.DEPLOYMENTS) public void createDeployments(CdsCreateEventContext context) { CqnInsert insert = context.getCqn(); List> results = new ArrayList<>(); @@ -136,7 +136,7 @@ public void createDeployments(CdsCreateEventContext context) { context.setResult(results); } - @On(entity = AICoreService.DEPLOYMENTS) + @On(entity = AICore.DEPLOYMENTS) public void updateDeployments(CdsUpdateEventContext context) { CqnUpdate update = context.getCqn(); CdsModel model = context.getModel(); @@ -152,7 +152,7 @@ public void updateDeployments(CdsUpdateEventContext context) { context.setResult(List.of(CdsData.create(existing))); } - @On(entity = AICoreService.DEPLOYMENTS) + @On(entity = AICore.DEPLOYMENTS) public void deleteDeployments(CdsDeleteEventContext context) { CqnDelete delete = context.getCqn(); CdsModel model = context.getModel(); @@ -165,7 +165,7 @@ public void deleteDeployments(CdsDeleteEventContext context) { // --- Configurations --- - @On(entity = AICoreService.CONFIGURATIONS) + @On(entity = AICore.CONFIGURATIONS) public void readConfigurations(CdsReadEventContext context) { CqnSelect select = context.getCqn(); CdsModel model = context.getModel(); @@ -181,7 +181,7 @@ public void readConfigurations(CdsReadEventContext context) { } } - @On(entity = AICoreService.CONFIGURATIONS) + @On(entity = AICore.CONFIGURATIONS) public void createConfigurations(CdsCreateEventContext context) { CqnInsert insert = context.getCqn(); List> results = new ArrayList<>(); diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandler.java index 9471662..016b833 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandler.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandler.java @@ -9,7 +9,7 @@ import com.sap.ai.sdk.core.model.BckndResourceGroupPatchRequest; import com.sap.ai.sdk.core.model.BckndResourceGroupsPostRequest; import com.sap.cds.CdsData; -import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.api.AICore; import com.sap.cds.feature.aicore.core.AICoreClients; import com.sap.cds.feature.aicore.core.AICoreConfig; import com.sap.cds.feature.aicore.core.DeploymentResolver; @@ -36,7 +36,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -@ServiceName(AICoreService.DEFAULT_NAME) +@ServiceName(AICore.SERVICE_NAME) public class ResourceGroupHandler extends AbstractCrudHandler { private static final Logger logger = LoggerFactory.getLogger(ResourceGroupHandler.class); From 2f33a4868cdde38ea5274efb0bbcf769d074cfab Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Wed, 17 Jun 2026 13:55:51 +0200 Subject: [PATCH 30/34] update tests --- .../core/AICoreServiceConfigurationTest.java | 25 +++--- .../AICoreServiceImplDeploymentIdTest.java | 89 +++++++++---------- .../core/MockAICoreServiceImplTest.java | 68 ++++++++++---- .../handler/ConfigurationHandlerTest.java | 19 ++-- .../core/handler/DeploymentHandlerTest.java | 19 ++-- .../handler/ResourceGroupHandlerTest.java | 35 +++++--- .../core/handler/TenantScopingTest.java | 19 ++-- 7 files changed, 170 insertions(+), 104 deletions(-) diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceConfigurationTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceConfigurationTest.java index c6612a6..42cb7a5 100644 --- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceConfigurationTest.java +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceConfigurationTest.java @@ -5,7 +5,8 @@ import static org.assertj.core.api.Assertions.assertThat; -import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.api.AICore; +import com.sap.cds.services.cds.RemoteService; import com.sap.cds.services.environment.CdsProperties; import com.sap.cds.services.impl.environment.SimplePropertiesProvider; import com.sap.cds.services.runtime.CdsRuntime; @@ -17,8 +18,8 @@ * CDS model. This verifies the full service registration and handler wiring lifecycle without heavy * Mockito mocks. * - *

Since the test runtime has no service bindings, the configuration always registers an {@link - * AICoreServiceImpl} with mock handlers regardless of environment variables. + *

Since the test runtime has no service bindings, the configuration always registers mock + * handlers regardless of environment variables. */ class AICoreServiceConfigurationTest { @@ -26,15 +27,16 @@ class AICoreServiceConfigurationTest { void noBinding_noMultiTenancy_registersService() { CdsRuntime runtime = CdsRuntimeConfigurer.create(new SimplePropertiesProvider(new CdsProperties())) + .environmentConfigurations() .cdsModel("edmx/csn.json") .serviceConfigurations() .eventHandlerConfigurations() .complete(); - AICoreService service = - runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_NAME); + RemoteService service = + runtime.getServiceCatalog().getService(RemoteService.class, AICore.SERVICE_NAME); - assertThat(service).isNotNull().isInstanceOf(AICoreServiceImpl.class); + assertThat(service).isNotNull(); } @Test @@ -48,15 +50,16 @@ void noBinding_withSidecarUrl_registersService() { CdsRuntime runtime = CdsRuntimeConfigurer.create(new SimplePropertiesProvider(props)) + .environmentConfigurations() .cdsModel("edmx/csn.json") .serviceConfigurations() .eventHandlerConfigurations() .complete(); - AICoreService service = - runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_NAME); + RemoteService service = + runtime.getServiceCatalog().getService(RemoteService.class, AICore.SERVICE_NAME); - assertThat(service).isNotNull().isInstanceOf(AICoreServiceImpl.class); + assertThat(service).isNotNull(); } @Test @@ -67,8 +70,8 @@ void noModel_skipsServiceRegistration() { .eventHandlerConfigurations() .complete(); - AICoreService service = - runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_NAME); + RemoteService service = + runtime.getServiceCatalog().getService(RemoteService.class, AICore.SERVICE_NAME); assertThat(service).isNull(); } diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplDeploymentIdTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplDeploymentIdTest.java index 9c66431..84979cf 100644 --- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplDeploymentIdTest.java +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplDeploymentIdTest.java @@ -23,16 +23,19 @@ import com.sap.ai.sdk.core.model.AiDeploymentList; import com.sap.ai.sdk.core.model.AiDeploymentResponseWithDetails; import com.sap.ai.sdk.core.model.AiDeploymentStatus; -import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.api.AICore; +import com.sap.cds.feature.aicore.api.DeploymentIdContext; import com.sap.cds.feature.aicore.api.ModelDeploymentSpec; +import com.sap.cds.feature.aicore.api.ResourceGroupContext; import com.sap.cds.feature.aicore.core.handler.AICoreApiHandler; +import com.sap.cds.services.cds.RemoteService; import com.sap.cds.services.environment.CdsProperties; +import com.sap.cds.services.environment.CdsProperties.Remote.RemoteServiceConfig; import com.sap.cds.services.impl.environment.SimplePropertiesProvider; import com.sap.cds.services.runtime.CdsRuntime; import com.sap.cds.services.runtime.CdsRuntimeConfigurer; import com.sap.cloud.sdk.services.openapi.apache.core.OpenApiRequestException; import java.lang.reflect.Field; -import java.util.HashMap; import java.util.List; import java.util.Map; import org.junit.jupiter.api.BeforeEach; @@ -53,7 +56,7 @@ class AICoreServiceImplDeploymentIdTest { private DeploymentApi deploymentApi; private ConfigurationApi configurationApi; private ResourceGroupApi resourceGroupApi; - private AICoreServiceImpl service; + private RemoteService service; private DeploymentResolver resolver; private final ModelDeploymentSpec spec = @@ -64,16 +67,19 @@ private String cacheKey() { } /** - * Creates an {@link AICoreServiceImpl} properly registered with a CDS runtime and the {@link + * Creates a {@link RemoteService} properly registered with a CDS runtime and the {@link * AICoreApiHandler} so that {@code emit()} dispatches to the handler. */ - private AICoreServiceImpl createService(boolean multiTenancy) { - TestPropertiesProvider props = new TestPropertiesProvider(); - props.setProperty("cds.ai.core.maxRetries", 1); - props.setProperty("cds.ai.core.initialDelayMs", 1L); - - CdsRuntimeConfigurer configurer = CdsRuntimeConfigurer.create(props); + private RemoteService createService(boolean multiTenancy) { + CdsProperties props = new CdsProperties(); + RemoteServiceConfig rsConfig = new RemoteServiceConfig(AICore.SERVICE_NAME); + rsConfig.setModel(AICore.SERVICE_NAME); + props.getRemote().getServices().put(AICore.SERVICE_NAME, rsConfig); + + CdsRuntimeConfigurer configurer = + CdsRuntimeConfigurer.create(new SimplePropertiesProvider(props)); configurer.cdsModel("edmx/csn.json"); + configurer.serviceConfigurations(); CdsRuntime runtime = configurer.getCdsRuntime(); AICoreConfig config = new AICoreConfig("default", "cds-", 1, 1L, multiTenancy); @@ -82,11 +88,10 @@ private AICoreServiceImpl createService(boolean multiTenancy) { deploymentApi, configurationApi, resourceGroupApi, mock(AiCoreService.class)); resolver = new DeploymentResolver(config, deploymentApi, resourceGroupApi); - AICoreServiceImpl svc = new AICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime); - configurer.service(svc); configurer.eventHandler(new AICoreApiHandler(config, clients, resolver)); configurer.complete(); - return svc; + + return runtime.getServiceCatalog().getService(RemoteService.class, AICore.SERVICE_NAME); } @BeforeEach @@ -105,7 +110,7 @@ void cacheHit_runningDeployment_returnsCachedIdWithoutQuery() throws Exception { when(running.getStatus()).thenReturn(AiDeploymentStatus.RUNNING); when(deploymentApi.get(RG, DEPLOYMENT_ID)).thenReturn(running); - String result = service.deploymentId(RG, spec); + String result = emitDeploymentId(service, RG, spec); assertThat(result).isEqualTo(DEPLOYMENT_ID); verify(deploymentApi).get(RG, DEPLOYMENT_ID); @@ -130,7 +135,7 @@ void cacheStale_404OnGet_invalidatesAndReturnsExistingFromQuery() throws Excepti when(deploymentApi.query(eq(RG), any(), any(), eq(SCENARIO), any(), any(), any(), any())) .thenReturn(list); - String result = service.deploymentId(RG, spec); + String result = emitDeploymentId(service, RG, spec); assertThat(result).isEqualTo(otherDeployment); assertThat(getDeploymentCache(resolver)).containsEntry(cacheKey(), otherDeployment); @@ -144,7 +149,7 @@ void cacheStale_5xxOnGet_propagatesAndPreservesCacheEntry() throws Exception { OpenApiRequestException serverError = new OpenApiRequestException("boom").statusCode(503); when(deploymentApi.get(RG, "still-valid-id")).thenThrow(serverError); - assertThatThrownBy(() -> service.deploymentId(RG, spec)).rootCause().isSameAs(serverError); + assertThatThrownBy(() -> emitDeploymentId(service, RG, spec)).rootCause().isSameAs(serverError); assertThat(getDeploymentCache(resolver)).containsEntry(cacheKey(), "still-valid-id"); verify(deploymentApi, never()).query(any(), any(), any(), any(), any(), any(), any(), any()); @@ -162,7 +167,7 @@ void noCache_existingMatchingDeployment_isReusedAndCached() throws Exception { when(deploymentApi.query(eq(RG), any(), any(), eq(SCENARIO), any(), any(), any(), any())) .thenReturn(list); - String result = service.deploymentId(RG, spec); + String result = emitDeploymentId(service, RG, spec); assertThat(result).isEqualTo(DEPLOYMENT_ID); assertThat(getDeploymentCache(resolver)).containsEntry(cacheKey(), DEPLOYMENT_ID); @@ -185,8 +190,8 @@ void secondCallUsesCachedResult_singleQueryToApi() { when(running.getStatus()).thenReturn(AiDeploymentStatus.RUNNING); when(deploymentApi.get(RG, DEPLOYMENT_ID)).thenReturn(running); - String first = service.deploymentId(RG, spec); - String second = service.deploymentId(RG, spec); + String first = emitDeploymentId(service, RG, spec); + String second = emitDeploymentId(service, RG, spec); assertThat(first).isEqualTo(DEPLOYMENT_ID); assertThat(second).isEqualTo(DEPLOYMENT_ID); @@ -197,15 +202,15 @@ void secondCallUsesCachedResult_singleQueryToApi() { @Test void resourceGroupForTenant_nullTenantId_returnsDefault() { - AICoreServiceImpl mtService = createService(true); + RemoteService mtService = createService(true); - String result = mtService.resourceGroupForTenant(null); + String result = emitResourceGroup(mtService, null); assertThat(result).isEqualTo("default"); } @Test void resourceGroupForTenant_multiTenancyDisabled_returnsDefault() { - String result = service.resourceGroupForTenant("any-tenant"); + String result = emitResourceGroup(service, "any-tenant"); assertThat(result).isEqualTo("default"); } @@ -232,7 +237,7 @@ void noCacheNoExistingDeployment_createsNewDeploymentWhenConfigExists() throws E when(runningPoll.getStatus()).thenReturn(AiDeploymentStatus.RUNNING); when(deploymentApi.get(RG, DEPLOYMENT_ID)).thenReturn(runningPoll); - String result = service.deploymentId(RG, spec); + String result = emitDeploymentId(service, RG, spec); assertThat(result).isEqualTo(DEPLOYMENT_ID); assertThat(getDeploymentCache(resolver)).containsEntry(cacheKey(), DEPLOYMENT_ID); @@ -244,6 +249,21 @@ void noCacheNoExistingDeployment_createsNewDeploymentWhenConfigExists() throws E // Helpers // ────────────────────────────────────────────────────────────────────────── + private static String emitDeploymentId(RemoteService svc, String rg, ModelDeploymentSpec spec) { + DeploymentIdContext ctx = DeploymentIdContext.create(); + ctx.setResourceGroupId(rg); + ctx.setSpec(spec); + svc.emit(ctx); + return ctx.getResult(); + } + + private static String emitResourceGroup(RemoteService svc, String tenantId) { + ResourceGroupContext ctx = ResourceGroupContext.create(); + ctx.setTenantId(tenantId); + svc.emit(ctx); + return ctx.getResult(); + } + @SuppressWarnings("unchecked") private static void putInDeploymentCache(DeploymentResolver resolver, String key, String value) throws Exception { @@ -260,27 +280,4 @@ private static Map getDeploymentCache(DeploymentResolver resolve field.setAccessible(true); return ((com.github.benmanes.caffeine.cache.Cache) field.get(resolver)).asMap(); } - - /** Properties provider that allows overriding specific keys for test configuration. */ - private static class TestPropertiesProvider extends SimplePropertiesProvider { - private final Map properties = new HashMap<>(); - - TestPropertiesProvider() { - super(new CdsProperties()); - } - - void setProperty(String key, Object value) { - properties.put(key, value); - } - - @Override - @SuppressWarnings("unchecked") - public T getProperty(String key, Class asClazz, T defaultValue) { - Object value = properties.get(key); - if (value != null && asClazz.isInstance(value)) { - return (T) value; - } - return defaultValue; - } - } } diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImplTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImplTest.java index c555b58..056e0fc 100644 --- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImplTest.java +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImplTest.java @@ -5,9 +5,12 @@ import static org.assertj.core.api.Assertions.assertThat; -import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.api.AICore; +import com.sap.cds.feature.aicore.api.DeploymentIdContext; import com.sap.cds.feature.aicore.api.ModelDeploymentSpec; +import com.sap.cds.feature.aicore.api.ResourceGroupContext; import com.sap.cds.feature.aicore.core.handler.MockAICoreApiHandler; +import com.sap.cds.services.cds.RemoteService; import com.sap.cds.services.environment.CdsProperties; import com.sap.cds.services.impl.environment.SimplePropertiesProvider; import com.sap.cds.services.runtime.CdsRuntime; @@ -21,7 +24,7 @@ */ class MockAICoreServiceImplTest { - private AICoreService createMockService(boolean multiTenancy) { + private RemoteService createMockService(boolean multiTenancy) { CdsProperties props = new CdsProperties(); if (multiTenancy) { CdsProperties.MultiTenancy mt = new CdsProperties.MultiTenancy(); @@ -33,55 +36,84 @@ private AICoreService createMockService(boolean multiTenancy) { CdsRuntime runtime = CdsRuntimeConfigurer.create(new SimplePropertiesProvider(props)) + .environmentConfigurations() .cdsModel("edmx/csn.json") .serviceConfigurations() .eventHandlerConfigurations() .complete(); - return runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_NAME); + return runtime.getServiceCatalog().getService(RemoteService.class, AICore.SERVICE_NAME); } @Test void noMultiTenancy_resourceGroupReturnsDefault() { - AICoreService service = createMockService(false); - assertThat(service.resourceGroup()).isEqualTo("default"); + RemoteService service = createMockService(false); + ResourceGroupContext rgCtx = ResourceGroupContext.create(); + service.emit(rgCtx); + assertThat(rgCtx.getResult()).isEqualTo("default"); } @Test void noMultiTenancy_resourceGroupForTenant_returnsDefault() { - AICoreService service = createMockService(false); - assertThat(service.resourceGroupForTenant("any-tenant")).isEqualTo("default"); + RemoteService service = createMockService(false); + ResourceGroupContext rgCtx = ResourceGroupContext.create(); + rgCtx.setTenantId("any-tenant"); + service.emit(rgCtx); + assertThat(rgCtx.getResult()).isEqualTo("default"); } @Test void multiTenancy_resourceGroupForTenant_returnsPrefixed() { - AICoreService service = createMockService(true); - String rg = service.resourceGroupForTenant("my-tenant"); - assertThat(rg).isEqualTo("cds-my-tenant"); + RemoteService service = createMockService(true); + ResourceGroupContext rgCtx = ResourceGroupContext.create(); + rgCtx.setTenantId("my-tenant"); + service.emit(rgCtx); + assertThat(rgCtx.getResult()).isEqualTo("cds-my-tenant"); } @Test void multiTenancy_resourceGroupForTenant_cachesResult() { - AICoreService service = createMockService(true); - String first = service.resourceGroupForTenant("t1"); - String second = service.resourceGroupForTenant("t1"); + RemoteService service = createMockService(true); + ResourceGroupContext rgCtx1 = ResourceGroupContext.create(); + rgCtx1.setTenantId("t1"); + service.emit(rgCtx1); + String first = rgCtx1.getResult(); + + ResourceGroupContext rgCtx2 = ResourceGroupContext.create(); + rgCtx2.setTenantId("t1"); + service.emit(rgCtx2); + String second = rgCtx2.getResult(); assertThat(first).isEqualTo(second); } @Test void deploymentId_returnsMockId() { - AICoreService service = createMockService(false); + RemoteService service = createMockService(false); var spec = new ModelDeploymentSpec("scenario", "exec", "cfg1", List.of(), d -> true); - String id = service.deploymentId("default", spec); + DeploymentIdContext depCtx = DeploymentIdContext.create(); + depCtx.setResourceGroupId("default"); + depCtx.setSpec(spec); + service.emit(depCtx); + String id = depCtx.getResult(); assertThat(id).startsWith("mock-deployment-"); } @Test void deploymentId_cachesSameResult() { - AICoreService service = createMockService(false); + RemoteService service = createMockService(false); var spec = new ModelDeploymentSpec("scenario", "exec", "cfg1", List.of(), d -> true); - String first = service.deploymentId("default", spec); - String second = service.deploymentId("default", spec); + + DeploymentIdContext depCtx1 = DeploymentIdContext.create(); + depCtx1.setResourceGroupId("default"); + depCtx1.setSpec(spec); + service.emit(depCtx1); + String first = depCtx1.getResult(); + + DeploymentIdContext depCtx2 = DeploymentIdContext.create(); + depCtx2.setResourceGroupId("default"); + depCtx2.setSpec(spec); + service.emit(depCtx2); + String second = depCtx2.getResult(); assertThat(first).isEqualTo(second); } } diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandlerTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandlerTest.java index 7fdb3e2..f6de01a 100644 --- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandlerTest.java +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandlerTest.java @@ -20,14 +20,15 @@ import com.sap.ai.sdk.core.model.AiConfigurationCreationResponse; import com.sap.ai.sdk.core.model.AiConfigurationList; import com.sap.cds.Result; -import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.api.AICore; import com.sap.cds.feature.aicore.core.AICoreClients; import com.sap.cds.feature.aicore.core.AICoreConfig; -import com.sap.cds.feature.aicore.core.AICoreServiceImpl; import com.sap.cds.feature.aicore.core.DeploymentResolver; import com.sap.cds.ql.Insert; import com.sap.cds.ql.Select; +import com.sap.cds.services.cds.RemoteService; import com.sap.cds.services.environment.CdsProperties; +import com.sap.cds.services.environment.CdsProperties.Remote.RemoteServiceConfig; import com.sap.cds.services.impl.environment.SimplePropertiesProvider; import com.sap.cds.services.request.RequestContext; import com.sap.cds.services.runtime.CdsRuntime; @@ -47,7 +48,7 @@ class ConfigurationHandlerTest { private static CdsRuntime runtime; - private static AICoreServiceImpl service; + private static RemoteService service; private static ConfigurationApi configurationApi; private static ResourceGroupApi resourceGroupApi; @@ -57,8 +58,14 @@ static void bootRuntime() { resourceGroupApi = mock(ResourceGroupApi.class); DeploymentApi deploymentApi = mock(DeploymentApi.class); - var configurer = CdsRuntimeConfigurer.create(new SimplePropertiesProvider(new CdsProperties())); + CdsProperties props = new CdsProperties(); + RemoteServiceConfig rsConfig = new RemoteServiceConfig(AICore.SERVICE_NAME); + rsConfig.setModel(AICore.SERVICE_NAME); + props.getRemote().getServices().put(AICore.SERVICE_NAME, rsConfig); + + var configurer = CdsRuntimeConfigurer.create(new SimplePropertiesProvider(props)); configurer.cdsModel("edmx/csn.json"); + configurer.serviceConfigurations(); runtime = configurer.getCdsRuntime(); AICoreConfig config = new AICoreConfig("default", "cds-", 10, 300, false); @@ -67,11 +74,11 @@ static void bootRuntime() { deploymentApi, configurationApi, resourceGroupApi, mock(AiCoreService.class)); DeploymentResolver resolver = new DeploymentResolver(config, deploymentApi, resourceGroupApi); - service = new AICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime); - configurer.service(service); configurer.eventHandler(new AICoreApiHandler(config, clients, resolver)); configurer.eventHandler(new ConfigurationHandler(config, clients, resolver)); configurer.complete(); + + service = runtime.getServiceCatalog().getService(RemoteService.class, AICore.SERVICE_NAME); } @BeforeEach diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandlerTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandlerTest.java index 0e47302..44743d5 100644 --- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandlerTest.java +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandlerTest.java @@ -21,16 +21,17 @@ import com.sap.ai.sdk.core.model.AiDeploymentModificationRequest; import com.sap.ai.sdk.core.model.AiExecutionStatus; import com.sap.cds.Result; -import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.api.AICore; import com.sap.cds.feature.aicore.core.AICoreClients; import com.sap.cds.feature.aicore.core.AICoreConfig; -import com.sap.cds.feature.aicore.core.AICoreServiceImpl; import com.sap.cds.feature.aicore.core.DeploymentResolver; import com.sap.cds.ql.Insert; import com.sap.cds.ql.Update; import com.sap.cds.services.ErrorStatuses; import com.sap.cds.services.ServiceException; +import com.sap.cds.services.cds.RemoteService; import com.sap.cds.services.environment.CdsProperties; +import com.sap.cds.services.environment.CdsProperties.Remote.RemoteServiceConfig; import com.sap.cds.services.impl.environment.SimplePropertiesProvider; import com.sap.cds.services.request.RequestContext; import com.sap.cds.services.runtime.CdsRuntime; @@ -50,7 +51,7 @@ class DeploymentHandlerTest { private static CdsRuntime runtime; - private static AICoreServiceImpl service; + private static RemoteService service; private static DeploymentApi deploymentApi; private static ResourceGroupApi resourceGroupApi; private static ConfigurationApi configurationApi; @@ -61,8 +62,14 @@ static void bootRuntime() { resourceGroupApi = mock(ResourceGroupApi.class); configurationApi = mock(ConfigurationApi.class); - var configurer = CdsRuntimeConfigurer.create(new SimplePropertiesProvider(new CdsProperties())); + CdsProperties props = new CdsProperties(); + RemoteServiceConfig rsConfig = new RemoteServiceConfig(AICore.SERVICE_NAME); + rsConfig.setModel(AICore.SERVICE_NAME); + props.getRemote().getServices().put(AICore.SERVICE_NAME, rsConfig); + + var configurer = CdsRuntimeConfigurer.create(new SimplePropertiesProvider(props)); configurer.cdsModel("edmx/csn.json"); + configurer.serviceConfigurations(); runtime = configurer.getCdsRuntime(); AICoreConfig config = new AICoreConfig("default", "cds-", 10, 300, false); @@ -71,11 +78,11 @@ static void bootRuntime() { deploymentApi, configurationApi, resourceGroupApi, mock(AiCoreService.class)); DeploymentResolver resolver = new DeploymentResolver(config, deploymentApi, resourceGroupApi); - service = new AICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime); - configurer.service(service); configurer.eventHandler(new AICoreApiHandler(config, clients, resolver)); configurer.eventHandler(new DeploymentHandler(config, clients, resolver)); configurer.complete(); + + service = runtime.getServiceCatalog().getService(RemoteService.class, AICore.SERVICE_NAME); } @BeforeEach diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandlerTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandlerTest.java index 4dfc443..a635b98 100644 --- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandlerTest.java +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandlerTest.java @@ -22,15 +22,16 @@ import com.sap.ai.sdk.core.model.BckndResourceGroupPatchRequest; import com.sap.ai.sdk.core.model.BckndResourceGroupsPostRequest; import com.sap.cds.Result; -import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.api.AICore; import com.sap.cds.feature.aicore.core.AICoreClients; import com.sap.cds.feature.aicore.core.AICoreConfig; -import com.sap.cds.feature.aicore.core.AICoreServiceImpl; import com.sap.cds.feature.aicore.core.DeploymentResolver; import com.sap.cds.ql.Insert; import com.sap.cds.ql.Select; import com.sap.cds.ql.Update; +import com.sap.cds.services.cds.RemoteService; import com.sap.cds.services.environment.CdsProperties; +import com.sap.cds.services.environment.CdsProperties.Remote.RemoteServiceConfig; import com.sap.cds.services.impl.environment.SimplePropertiesProvider; import com.sap.cds.services.request.RequestContext; import com.sap.cds.services.runtime.CdsRuntime; @@ -51,7 +52,7 @@ class ResourceGroupHandlerTest { private static CdsRuntime runtime; - private static AICoreServiceImpl service; + private static RemoteService service; private static ResourceGroupApi resourceGroupApi; @BeforeAll @@ -60,8 +61,14 @@ static void bootRuntime() { DeploymentApi deploymentApi = mock(DeploymentApi.class); ConfigurationApi configurationApi = mock(ConfigurationApi.class); - var configurer = CdsRuntimeConfigurer.create(new SimplePropertiesProvider(new CdsProperties())); + CdsProperties props = new CdsProperties(); + RemoteServiceConfig rsConfig = new RemoteServiceConfig(AICore.SERVICE_NAME); + rsConfig.setModel(AICore.SERVICE_NAME); + props.getRemote().getServices().put(AICore.SERVICE_NAME, rsConfig); + + var configurer = CdsRuntimeConfigurer.create(new SimplePropertiesProvider(props)); configurer.cdsModel("edmx/csn.json"); + configurer.serviceConfigurations(); runtime = configurer.getCdsRuntime(); AICoreConfig config = new AICoreConfig("default", "cds-", 10, 300, false); @@ -70,11 +77,11 @@ static void bootRuntime() { deploymentApi, configurationApi, resourceGroupApi, mock(AiCoreService.class)); DeploymentResolver resolver = new DeploymentResolver(config, deploymentApi, resourceGroupApi); - service = new AICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime); - configurer.service(service); configurer.eventHandler(new AICoreApiHandler(config, clients, resolver)); configurer.eventHandler(new ResourceGroupHandler(config, clients, resolver)); configurer.complete(); + + service = runtime.getServiceCatalog().getService(RemoteService.class, AICore.SERVICE_NAME); } @BeforeEach @@ -199,7 +206,7 @@ void onUpdate_withoutLabels_callsPatchWithoutLabels() { class MultiTenancyTests { private static CdsRuntime mtRuntime; - private static AICoreServiceImpl mtService; + private static RemoteService mtService; private static ResourceGroupApi mtResourceGroupApi; @BeforeAll @@ -208,9 +215,14 @@ static void bootMtRuntime() { DeploymentApi deploymentApi = mock(DeploymentApi.class); ConfigurationApi configurationApi = mock(ConfigurationApi.class); - var configurer = - CdsRuntimeConfigurer.create(new SimplePropertiesProvider(new CdsProperties())); + CdsProperties props = new CdsProperties(); + RemoteServiceConfig rsConfig = new RemoteServiceConfig(AICore.SERVICE_NAME); + rsConfig.setModel(AICore.SERVICE_NAME); + props.getRemote().getServices().put(AICore.SERVICE_NAME, rsConfig); + + var configurer = CdsRuntimeConfigurer.create(new SimplePropertiesProvider(props)); configurer.cdsModel("edmx/csn.json"); + configurer.serviceConfigurations(); mtRuntime = configurer.getCdsRuntime(); AICoreConfig config = new AICoreConfig("default", "cds-", 10, 300, true); @@ -220,11 +232,12 @@ static void bootMtRuntime() { DeploymentResolver resolver = new DeploymentResolver(config, deploymentApi, mtResourceGroupApi); - mtService = new AICoreServiceImpl(AICoreService.DEFAULT_NAME, mtRuntime); - configurer.service(mtService); configurer.eventHandler(new AICoreApiHandler(config, clients, resolver)); configurer.eventHandler(new ResourceGroupHandler(config, clients, resolver)); configurer.complete(); + + mtService = + mtRuntime.getServiceCatalog().getService(RemoteService.class, AICore.SERVICE_NAME); } @BeforeEach diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/TenantScopingTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/TenantScopingTest.java index 078779c..168b5fc 100644 --- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/TenantScopingTest.java +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/TenantScopingTest.java @@ -18,14 +18,15 @@ import com.sap.ai.sdk.core.model.BckndResourceGroup; import com.sap.ai.sdk.core.model.BckndResourceGroupLabel; import com.sap.cds.Result; -import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.api.AICore; import com.sap.cds.feature.aicore.core.AICoreClients; import com.sap.cds.feature.aicore.core.AICoreConfig; -import com.sap.cds.feature.aicore.core.AICoreServiceImpl; import com.sap.cds.feature.aicore.core.DeploymentResolver; import com.sap.cds.ql.Select; import com.sap.cds.services.ServiceException; +import com.sap.cds.services.cds.RemoteService; import com.sap.cds.services.environment.CdsProperties; +import com.sap.cds.services.environment.CdsProperties.Remote.RemoteServiceConfig; import com.sap.cds.services.impl.environment.SimplePropertiesProvider; import com.sap.cds.services.request.RequestContext; import com.sap.cds.services.runtime.CdsRuntime; @@ -44,7 +45,7 @@ class TenantScopingTest { private static CdsRuntime runtime; - private static AICoreServiceImpl service; + private static RemoteService service; private static DeploymentApi deploymentApi; private static ResourceGroupApi resourceGroupApi; @@ -54,8 +55,14 @@ static void bootRuntime() { resourceGroupApi = mock(ResourceGroupApi.class); ConfigurationApi configurationApi = mock(ConfigurationApi.class); - var configurer = CdsRuntimeConfigurer.create(new SimplePropertiesProvider(new CdsProperties())); + CdsProperties props = new CdsProperties(); + RemoteServiceConfig rsConfig = new RemoteServiceConfig(AICore.SERVICE_NAME); + rsConfig.setModel(AICore.SERVICE_NAME); + props.getRemote().getServices().put(AICore.SERVICE_NAME, rsConfig); + + var configurer = CdsRuntimeConfigurer.create(new SimplePropertiesProvider(props)); configurer.cdsModel("edmx/csn.json"); + configurer.serviceConfigurations(); runtime = configurer.getCdsRuntime(); AICoreConfig config = new AICoreConfig("default", "cds-", 10, 300, true); @@ -64,11 +71,11 @@ static void bootRuntime() { deploymentApi, configurationApi, resourceGroupApi, mock(AiCoreService.class)); DeploymentResolver resolver = new DeploymentResolver(config, deploymentApi, resourceGroupApi); - service = new AICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime); - configurer.service(service); configurer.eventHandler(new AICoreApiHandler(config, clients, resolver)); configurer.eventHandler(new DeploymentHandler(config, clients, resolver)); configurer.complete(); + + service = runtime.getServiceCatalog().getService(RemoteService.class, AICore.SERVICE_NAME); } @BeforeEach From 972ea668ec46b49ea715f11eb4cca59dc3a1856f Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Wed, 17 Jun 2026 13:57:31 +0200 Subject: [PATCH 31/34] update recommendations --- .../FioriRecommendationHandler.java | 6 ++-- .../RecommendationConfiguration.java | 33 ++++++++++++++----- .../api/RecommendationClientResolver.java | 4 +-- .../api/RptInferenceClient.java | 17 +++++++--- .../FioriRecommendationHandlerTest.java | 4 +-- .../RecommendationConfigurationTest.java | 10 +++--- 6 files changed, 50 insertions(+), 24 deletions(-) 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 7a539ff..f00ce7e 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 @@ -6,12 +6,12 @@ import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; import com.sap.cds.CdsData; -import com.sap.cds.feature.aicore.api.AICoreService; import com.sap.cds.feature.recommendation.api.RecommendationClient; import com.sap.cds.feature.recommendation.api.RecommendationClientResolver; import com.sap.cds.reflect.CdsStructuredType; import com.sap.cds.services.cds.ApplicationService; import com.sap.cds.services.cds.CdsReadEventContext; +import com.sap.cds.services.cds.RemoteService; import com.sap.cds.services.draft.Drafts; import com.sap.cds.services.handler.EventHandler; import com.sap.cds.services.handler.annotations.After; @@ -31,7 +31,7 @@ class FioriRecommendationHandler implements EventHandler { private static final int DEFAULT_CONTEXT_ROW_LIMIT = 2000; private static final String SAP_RECOMMENDATIONS = "SAP_Recommendations"; - private final AICoreService aiCoreService; + private final RemoteService aiCoreService; private final RecommendationClientResolver clientResolver; private final PersistenceService db; private final RecommendationResultParser resultParser = new RecommendationResultParser(); @@ -42,7 +42,7 @@ class FioriRecommendationHandler implements EventHandler { Caffeine.newBuilder().maximumSize(10_000).build(); FioriRecommendationHandler( - AICoreService aiCoreService, + RemoteService aiCoreService, RecommendationClientResolver clientResolver, PersistenceService db) { this.aiCoreService = aiCoreService; 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 e31a497..1d3df83 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 @@ -3,12 +3,16 @@ */ package com.sap.cds.feature.recommendation; -import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.api.AICore; +import com.sap.cds.feature.aicore.api.DeploymentIdContext; +import com.sap.cds.feature.aicore.api.InferenceClientContext; +import com.sap.cds.feature.aicore.api.ResourceGroupContext; import com.sap.cds.feature.recommendation.api.RecommendationClient; import com.sap.cds.feature.recommendation.api.RecommendationClientResolver; import com.sap.cds.feature.recommendation.api.RptInferenceClient; import com.sap.cds.feature.recommendation.api.RptModelSpec; import com.sap.cds.services.ServiceCatalog; +import com.sap.cds.services.cds.RemoteService; import com.sap.cds.services.persistence.PersistenceService; import com.sap.cds.services.runtime.CdsRuntime; import com.sap.cds.services.runtime.CdsRuntimeConfiguration; @@ -26,8 +30,8 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) { CdsRuntime runtime = configurer.getCdsRuntime(); ServiceCatalog serviceCatalog = runtime.getServiceCatalog(); - AICoreService aiCoreService = - serviceCatalog.getService(AICoreService.class, AICoreService.DEFAULT_NAME); + RemoteService aiCoreService = + serviceCatalog.getService(RemoteService.class, AICore.SERVICE_NAME); if (aiCoreService == null) { logger.info("No AICoreService found, skipping Fiori recommendation handler registration."); @@ -60,13 +64,26 @@ private static boolean hasAICoreBinding(CdsRuntime runtime) { .getEnvironment() .getServiceBindings() .filter(b -> ServiceBindingUtils.matches(b, "aicore")) - .findFirst() + .findAny() .isPresent(); } - private static RecommendationClient resolveRptClient(AICoreService service) { - String resourceGroup = service.resourceGroup(); - String deploymentId = service.deploymentId(resourceGroup, RptModelSpec.rpt1()); - return new RptInferenceClient(service.inferenceClient(resourceGroup, deploymentId)); + private static RecommendationClient resolveRptClient(RemoteService service) { + ResourceGroupContext rgCtx = ResourceGroupContext.create(); + service.emit(rgCtx); + String resourceGroup = rgCtx.getResult(); + + DeploymentIdContext depCtx = DeploymentIdContext.create(); + depCtx.setResourceGroupId(resourceGroup); + depCtx.setSpec(RptModelSpec.rpt1()); + service.emit(depCtx); + String deploymentId = depCtx.getResult(); + + InferenceClientContext infCtx = InferenceClientContext.create(); + infCtx.setResourceGroupId(resourceGroup); + infCtx.setDeploymentId(deploymentId); + service.emit(infCtx); + + return new RptInferenceClient(infCtx.getResult()); } } diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RecommendationClientResolver.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RecommendationClientResolver.java index 2f83755..fdd71c6 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RecommendationClientResolver.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RecommendationClientResolver.java @@ -3,12 +3,12 @@ */ package com.sap.cds.feature.recommendation.api; -import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.services.cds.RemoteService; // The annotation @FunctionalInterface ensures this interface has only one method, such that // callers can supply a custom client by providing this one method e.g. via a lambda. @FunctionalInterface public interface RecommendationClientResolver { - RecommendationClient resolve(AICoreService aiCoreService); + RecommendationClient resolve(RemoteService aiCoreService); } diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java index 6bb189f..ed7d238 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java @@ -31,10 +31,19 @@ *

Example usage: * *

{@code
- * AICoreService service = ...;
- * String rg = service.resourceGroup();
- * String deploymentId = service.deploymentId(rg, RptModelSpec.rpt1());
- * RptInferenceClient client = new RptInferenceClient(service.inferenceClient(rg, deploymentId));
+ * RemoteService service = runtime.getServiceCatalog().getService(RemoteService.class, AICore.SERVICE_NAME);
+ * ResourceGroupContext rgCtx = ResourceGroupContext.create();
+ * service.emit(rgCtx);
+ * String rg = rgCtx.getResult();
+ * DeploymentIdContext depCtx = DeploymentIdContext.create();
+ * depCtx.setResourceGroupId(rg);
+ * depCtx.setSpec(RptModelSpec.rpt1());
+ * service.emit(depCtx);
+ * InferenceClientContext infCtx = InferenceClientContext.create();
+ * infCtx.setResourceGroupId(rg);
+ * infCtx.setDeploymentId(depCtx.getResult());
+ * service.emit(infCtx);
+ * RptInferenceClient client = new RptInferenceClient(infCtx.getResult());
  * List predictions = client.predict(predictionRow, contextRows, List.of("targetColumn"), List.of("ID"));
  * }
*/ 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 48a8c25..34b9972 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 @@ -13,11 +13,11 @@ import com.sap.cds.CdsData; import com.sap.cds.Result; import com.sap.cds.ResultBuilder; -import com.sap.cds.feature.aicore.api.AICoreService; import com.sap.cds.feature.recommendation.api.RecommendationClient; import com.sap.cds.ql.cqn.CqnSelect; import com.sap.cds.services.Service; import com.sap.cds.services.cds.CdsReadEventContext; +import com.sap.cds.services.cds.RemoteService; import com.sap.cds.services.impl.utils.CdsServiceUtils; import com.sap.cds.services.persistence.PersistenceService; import com.sap.cds.services.request.RequestContext; @@ -45,7 +45,7 @@ class FioriRecommendationHandlerTest { private static PersistenceService db; @Mock(answer = Answers.CALLS_REAL_METHODS) - private AICoreService aiCoreService; + private RemoteService aiCoreService; private FioriRecommendationHandler cut; private RecommendationClient predictionClient; diff --git a/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/RecommendationConfigurationTest.java b/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/RecommendationConfigurationTest.java index 8b3ebce..4ab68d8 100644 --- a/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/RecommendationConfigurationTest.java +++ b/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/RecommendationConfigurationTest.java @@ -5,8 +5,9 @@ import static org.mockito.Mockito.*; -import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.api.AICore; import com.sap.cds.services.ServiceCatalog; +import com.sap.cds.services.cds.RemoteService; import com.sap.cds.services.environment.CdsEnvironment; import com.sap.cds.services.persistence.PersistenceService; import com.sap.cds.services.runtime.CdsRuntime; @@ -24,7 +25,7 @@ class RecommendationConfigurationTest { @Mock private CdsRuntime runtime; @Mock private ServiceCatalog serviceCatalog; @Mock private CdsEnvironment environment; - @Mock private AICoreService aiCoreService; + @Mock private RemoteService aiCoreService; @Mock private PersistenceService persistenceService; @Test @@ -33,7 +34,7 @@ void aiCoreServiceFound_registersHandler() { when(runtime.getServiceCatalog()).thenReturn(serviceCatalog); when(runtime.getEnvironment()).thenReturn(environment); when(environment.getServiceBindings()).thenReturn(Stream.empty()); - when(serviceCatalog.getService(AICoreService.class, AICoreService.DEFAULT_NAME)) + when(serviceCatalog.getService(RemoteService.class, AICore.SERVICE_NAME)) .thenReturn(aiCoreService); when(serviceCatalog.getService(PersistenceService.class, PersistenceService.DEFAULT_NAME)) .thenReturn(persistenceService); @@ -47,8 +48,7 @@ void aiCoreServiceFound_registersHandler() { void aiCoreServiceNull_doesNotRegisterHandler() { when(configurer.getCdsRuntime()).thenReturn(runtime); when(runtime.getServiceCatalog()).thenReturn(serviceCatalog); - when(serviceCatalog.getService(AICoreService.class, AICoreService.DEFAULT_NAME)) - .thenReturn(null); + when(serviceCatalog.getService(RemoteService.class, AICore.SERVICE_NAME)).thenReturn(null); new RecommendationConfiguration().eventHandlers(configurer); From e2cff87625b8aa1e85111e536bb66e6010509b97 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Wed, 17 Jun 2026 13:58:25 +0200 Subject: [PATCH 32/34] update itests --- .../aicore/itest/mt/MtxLifecycleTest.java | 15 ++++--- .../itest/mt/SubscribeUnsubscribeTest.java | 15 ++++--- .../aicore/itest/mt/TenantIsolationTest.java | 28 ++++++++---- .../aicore/itest/AICoreServiceTest.java | 39 +++++++++++----- .../cds/feature/aicore/itest/ActionTest.java | 44 ++++++++++++++----- .../aicore/itest/BaseIntegrationTest.java | 26 ++++++----- .../aicore/itest/ConfigurationTest.java | 10 ++--- .../feature/aicore/itest/DeploymentTest.java | 8 ++-- .../aicore/itest/MultiTenancyTest.java | 37 ++++++++++++---- .../aicore/itest/ResourceGroupTest.java | 16 +++---- 10 files changed, 162 insertions(+), 76 deletions(-) diff --git a/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/MtxLifecycleTest.java b/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/MtxLifecycleTest.java index 6e88c40..1ad8aa5 100644 --- a/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/MtxLifecycleTest.java +++ b/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/MtxLifecycleTest.java @@ -7,8 +7,10 @@ import static org.assertj.core.api.Assertions.assertThatCode; import com.fasterxml.jackson.databind.ObjectMapper; -import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.api.AICore; +import com.sap.cds.feature.aicore.api.ResourceGroupContext; import com.sap.cds.feature.aicore.itest.mt.utils.SubscriptionEndpointClient; +import com.sap.cds.services.cds.RemoteService; import com.sap.cds.services.runtime.CdsRuntime; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -57,19 +59,22 @@ void unsubscribe_isIdempotent() throws Exception { @Test void subscribeUnsubscribe_repeatedTwice_completesCleanly() throws Exception { - AICoreService service = getService(); + RemoteService service = getService(); for (int i = 0; i < 2; i++) { subscriptionEndpointClient.subscribeTenant(TENANT); // After subscribe, the service should resolve a resource group for this tenant - String rg = service.resourceGroupForTenant(TENANT); + ResourceGroupContext rgCtx = ResourceGroupContext.create(); + rgCtx.setTenantId(TENANT); + service.emit(rgCtx); + String rg = rgCtx.getResult(); assertThat(rg).isNotNull().isNotBlank(); subscriptionEndpointClient.unsubscribeTenant(TENANT); } } - private AICoreService getService() { - return runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_NAME); + private RemoteService getService() { + return runtime.getServiceCatalog().getService(RemoteService.class, AICore.SERVICE_NAME); } } diff --git a/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/SubscribeUnsubscribeTest.java b/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/SubscribeUnsubscribeTest.java index eb1b9d2..fa53769 100644 --- a/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/SubscribeUnsubscribeTest.java +++ b/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/SubscribeUnsubscribeTest.java @@ -9,8 +9,10 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import com.fasterxml.jackson.databind.ObjectMapper; -import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.api.AICore; +import com.sap.cds.feature.aicore.api.ResourceGroupContext; import com.sap.cds.feature.aicore.itest.mt.utils.SubscriptionEndpointClient; +import com.sap.cds.services.cds.RemoteService; import com.sap.cds.services.runtime.CdsRuntime; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -50,12 +52,15 @@ void subscribeTenant_thenServiceIsReachable() throws Exception { @Test void subscribeTenant_createsResourceGroup() throws Exception { - AICoreService service = getService(); + RemoteService service = getService(); subscriptionEndpointClient.subscribeTenant("tenant-3"); // After subscription, the service should be able to resolve a resource group for the tenant - String resourceGroup = service.resourceGroupForTenant("tenant-3"); + ResourceGroupContext rgCtx = ResourceGroupContext.create(); + rgCtx.setTenantId("tenant-3"); + service.emit(rgCtx); + String resourceGroup = rgCtx.getResult(); assertThat(resourceGroup).isNotNull().isNotBlank(); } @@ -82,7 +87,7 @@ void tearDown() { } } - private AICoreService getService() { - return runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_NAME); + private RemoteService getService() { + return runtime.getServiceCatalog().getService(RemoteService.class, AICore.SERVICE_NAME); } } diff --git a/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/TenantIsolationTest.java b/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/TenantIsolationTest.java index 7aa06b8..5e44cfc 100644 --- a/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/TenantIsolationTest.java +++ b/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/TenantIsolationTest.java @@ -6,9 +6,11 @@ import static org.assertj.core.api.Assertions.assertThat; import com.fasterxml.jackson.databind.ObjectMapper; -import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.api.AICore; +import com.sap.cds.feature.aicore.api.ResourceGroupContext; import com.sap.cds.feature.aicore.core.AICoreConfig; import com.sap.cds.feature.aicore.itest.mt.utils.SubscriptionEndpointClient; +import com.sap.cds.services.cds.RemoteService; import com.sap.cds.services.environment.CdsProperties; import com.sap.cds.services.runtime.CdsRuntime; import org.junit.jupiter.api.AfterEach; @@ -44,13 +46,20 @@ void multiTenancyEnabled() { @Test void differentTenants_getDifferentResourceGroups() throws Exception { - AICoreService service = getService(); + RemoteService service = getService(); subscriptionEndpointClient.subscribeTenant("tenant-1"); subscriptionEndpointClient.subscribeTenant("tenant-2"); - String rg1 = service.resourceGroupForTenant("tenant-1"); - String rg2 = service.resourceGroupForTenant("tenant-2"); + ResourceGroupContext rgCtx1 = ResourceGroupContext.create(); + rgCtx1.setTenantId("tenant-1"); + service.emit(rgCtx1); + String rg1 = rgCtx1.getResult(); + + ResourceGroupContext rgCtx2 = ResourceGroupContext.create(); + rgCtx2.setTenantId("tenant-2"); + service.emit(rgCtx2); + String rg2 = rgCtx2.getResult(); assertThat(rg1).isNotNull(); assertThat(rg2).isNotNull(); @@ -60,16 +69,19 @@ void differentTenants_getDifferentResourceGroups() throws Exception { @Test void resourceGroupPrefix_applied() throws Exception { AICoreConfig config = getConfig(); - AICoreService service = getService(); + RemoteService service = getService(); subscriptionEndpointClient.subscribeTenant("tenant-1"); - String rg = service.resourceGroupForTenant("tenant-1"); + ResourceGroupContext rgCtx = ResourceGroupContext.create(); + rgCtx.setTenantId("tenant-1"); + service.emit(rgCtx); + String rg = rgCtx.getResult(); assertThat(rg).startsWith(config.resourceGroupPrefix()); } - private AICoreService getService() { - return runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_NAME); + private RemoteService getService() { + return runtime.getServiceCatalog().getService(RemoteService.class, AICore.SERVICE_NAME); } private AICoreConfig getConfig() { diff --git a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/AICoreServiceTest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/AICoreServiceTest.java index ce0c89b..795d6f8 100644 --- a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/AICoreServiceTest.java +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/AICoreServiceTest.java @@ -5,9 +5,11 @@ import static org.assertj.core.api.Assertions.assertThat; -import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.api.DeploymentIdContext; +import com.sap.cds.feature.aicore.api.ResourceGroupContext; import com.sap.cds.feature.aicore.core.AICoreConfig; import com.sap.cds.feature.recommendation.api.RptModelSpec; +import com.sap.cds.services.cds.RemoteService; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestInstance; @@ -23,15 +25,18 @@ void prepareDeployment() { @Test void service_isRegistered() { assertThat(getAICoreService()).isNotNull(); - assertThat(getAICoreService()).isInstanceOf(AICoreService.class); + assertThat(getAICoreService()).isInstanceOf(RemoteService.class); } @Test void resourceGroupForTenant_singleTenancy_returnsDefault() { AICoreConfig config = getAICoreConfig(); - AICoreService service = getAICoreService(); + RemoteService service = getAICoreService(); if (!config.multiTenancyEnabled()) { - String result = service.resourceGroupForTenant("any-tenant"); + ResourceGroupContext rgCtx = ResourceGroupContext.create(); + rgCtx.setTenantId("any-tenant"); + service.emit(rgCtx); + String result = rgCtx.getResult(); assertThat(result).isEqualTo(config.defaultResourceGroup()); } } @@ -39,29 +44,43 @@ void resourceGroupForTenant_singleTenancy_returnsDefault() { @Test void resourceGroupForTenant_multiTenancy_createsOrFindsGroup() { AICoreConfig config = getAICoreConfig(); - AICoreService service = getAICoreService(); + RemoteService service = getAICoreService(); if (config.multiTenancyEnabled()) { String tenantId = "itest-svc-tenant-" + System.currentTimeMillis(); - String resourceGroupId = service.resourceGroupForTenant(tenantId); + ResourceGroupContext rgCtx = ResourceGroupContext.create(); + rgCtx.setTenantId(tenantId); + service.emit(rgCtx); + String resourceGroupId = rgCtx.getResult(); assertThat(resourceGroupId).startsWith(config.resourceGroupPrefix()); assertThat(resourceGroupId).contains(tenantId); // Second call should return cached value - String cached = service.resourceGroupForTenant(tenantId); + ResourceGroupContext rgCtx2 = ResourceGroupContext.create(); + rgCtx2.setTenantId(tenantId); + service.emit(rgCtx2); + String cached = rgCtx2.getResult(); assertThat(cached).isEqualTo(resourceGroupId); } } @Test void deploymentId_returnsDeploymentId() { - AICoreService service = getAICoreService(); + RemoteService service = getAICoreService(); String resourceGroup = getAICoreConfig().defaultResourceGroup(); - String deploymentId = service.deploymentId(resourceGroup, RptModelSpec.rpt1()); + DeploymentIdContext depCtx = DeploymentIdContext.create(); + depCtx.setResourceGroupId(resourceGroup); + depCtx.setSpec(RptModelSpec.rpt1()); + service.emit(depCtx); + String deploymentId = depCtx.getResult(); assertThat(deploymentId).isNotNull().isNotBlank(); // Second call should use cache - String cached = service.deploymentId(resourceGroup, RptModelSpec.rpt1()); + DeploymentIdContext depCtx2 = DeploymentIdContext.create(); + depCtx2.setResourceGroupId(resourceGroup); + depCtx2.setSpec(RptModelSpec.rpt1()); + service.emit(depCtx2); + String cached = depCtx2.getResult(); assertThat(cached).isEqualTo(deploymentId); } diff --git a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ActionTest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ActionTest.java index 0e7bf34..39f430e 100644 --- a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ActionTest.java +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ActionTest.java @@ -9,12 +9,13 @@ import com.sap.cds.Result; import com.sap.cds.Row; -import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.api.DeploymentIdContext; +import com.sap.cds.feature.aicore.api.ResourceGroupContext; import com.sap.cds.feature.aicore.core.AICoreConfig; import com.sap.cds.feature.recommendation.api.RptModelSpec; import com.sap.cds.ql.Select; import com.sap.cds.ql.Update; -import com.sap.cds.services.cds.CqnService; +import com.sap.cds.services.cds.RemoteService; import java.util.Map; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Disabled; @@ -34,39 +35,58 @@ void ensureResourceGroupReady() { @Test void resourceGroupForTenant_singleTenancy_returnsDefault() { AICoreConfig config = getAICoreConfig(); - AICoreService service = getAICoreService(); + RemoteService service = getAICoreService(); assumeFalse(config.multiTenancyEnabled(), "Multi-tenancy is enabled"); - String result = service.resourceGroupForTenant("any-tenant-id"); + ResourceGroupContext rgCtx = ResourceGroupContext.create(); + rgCtx.setTenantId("any-tenant-id"); + service.emit(rgCtx); + String result = rgCtx.getResult(); assertThat(result).isEqualTo(config.defaultResourceGroup()); } @Test void resourceGroupForTenant_multiTenancy_createsGroup() { AICoreConfig config = getAICoreConfig(); - AICoreService service = getAICoreService(); + RemoteService service = getAICoreService(); assumeTrue(config.multiTenancyEnabled(), "Multi-tenancy is not enabled"); String tenantId = "itest-action-tenant-" + System.currentTimeMillis(); - String resourceGroupId = service.resourceGroupForTenant(tenantId); + ResourceGroupContext rgCtx = ResourceGroupContext.create(); + rgCtx.setTenantId(tenantId); + service.emit(rgCtx); + String resourceGroupId = rgCtx.getResult(); assertThat(resourceGroupId).startsWith(config.resourceGroupPrefix()); assertThat(resourceGroupId).contains(tenantId); } @Test void deploymentId_returnsValidDeployment() { - AICoreService service = getAICoreService(); + RemoteService service = getAICoreService(); String resourceGroup = getAICoreConfig().defaultResourceGroup(); - String deploymentId = service.deploymentId(resourceGroup, RptModelSpec.rpt1()); + DeploymentIdContext depCtx = DeploymentIdContext.create(); + depCtx.setResourceGroupId(resourceGroup); + depCtx.setSpec(RptModelSpec.rpt1()); + service.emit(depCtx); + String deploymentId = depCtx.getResult(); assertThat(deploymentId).isNotNull().isNotBlank(); } @Test void deploymentId_cachedOnSecondCall() { - AICoreService service = getAICoreService(); + RemoteService service = getAICoreService(); String resourceGroup = getAICoreConfig().defaultResourceGroup(); - String first = service.deploymentId(resourceGroup, RptModelSpec.rpt1()); - String second = service.deploymentId(resourceGroup, RptModelSpec.rpt1()); + DeploymentIdContext depCtx1 = DeploymentIdContext.create(); + depCtx1.setResourceGroupId(resourceGroup); + depCtx1.setSpec(RptModelSpec.rpt1()); + service.emit(depCtx1); + String first = depCtx1.getResult(); + + DeploymentIdContext depCtx2 = DeploymentIdContext.create(); + depCtx2.setResourceGroupId(resourceGroup); + depCtx2.setSpec(RptModelSpec.rpt1()); + service.emit(depCtx2); + String second = depCtx2.getResult(); assertThat(second).isEqualTo(first); } @@ -75,7 +95,7 @@ void deploymentId_cachedOnSecondCall() { + "re-enable once test creates its own isolated deployment") @Test void stop_deployment_changesTargetStatus() { - CqnService service = getAICoreCqnService(); + RemoteService service = getAICoreCqnService(); String resourceGroup = getAICoreConfig().defaultResourceGroup(); Result deployments = diff --git a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/BaseIntegrationTest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/BaseIntegrationTest.java index 06a09af..38dd0ca 100644 --- a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/BaseIntegrationTest.java +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/BaseIntegrationTest.java @@ -5,12 +5,13 @@ import com.sap.cds.Result; import com.sap.cds.Row; -import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.api.AICore; +import com.sap.cds.feature.aicore.api.DeploymentIdContext; import com.sap.cds.feature.aicore.core.AICoreConfig; import com.sap.cds.feature.recommendation.api.RptModelSpec; import com.sap.cds.ql.Insert; import com.sap.cds.ql.Select; -import com.sap.cds.services.cds.CqnService; +import com.sap.cds.services.cds.RemoteService; import com.sap.cds.services.environment.CdsProperties; import com.sap.cds.services.runtime.CdsRuntime; import java.util.List; @@ -39,8 +40,8 @@ public abstract class BaseIntegrationTest { @Autowired protected CdsRuntime runtime; - protected AICoreService getAICoreService() { - return runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_NAME); + protected RemoteService getAICoreService() { + return runtime.getServiceCatalog().getService(RemoteService.class, AICore.SERVICE_NAME); } protected AICoreConfig getAICoreConfig() { @@ -50,8 +51,8 @@ protected AICoreConfig getAICoreConfig() { return AICoreConfig.from(runtime.getEnvironment(), mt); } - protected CqnService getAICoreCqnService() { - return (CqnService) getAICoreService(); + protected RemoteService getAICoreCqnService() { + return getAICoreService(); } protected String ensureRptDeploymentReady() { @@ -60,11 +61,16 @@ protected String ensureRptDeploymentReady() { resourceGroup, rg -> { ensureResourceGroupProvisioned(getAICoreCqnService(), rg); - return getAICoreService().deploymentId(rg, RptModelSpec.rpt1()); + RemoteService service = getAICoreService(); + DeploymentIdContext depCtx = DeploymentIdContext.create(); + depCtx.setResourceGroupId(rg); + depCtx.setSpec(RptModelSpec.rpt1()); + service.emit(depCtx); + return depCtx.getResult(); }); } - protected void ensureResourceGroupProvisioned(CqnService service, String resourceGroup) { + protected void ensureResourceGroupProvisioned(RemoteService service, String resourceGroup) { if (!resourceGroupExists(service, resourceGroup)) { logger.info("Creating resource group {} with itest owner label", resourceGroup); service.run( @@ -79,7 +85,7 @@ protected void ensureResourceGroupProvisioned(CqnService service, String resourc waitForResourceGroupProvisioned(service, resourceGroup); } - private boolean resourceGroupExists(CqnService service, String resourceGroup) { + private boolean resourceGroupExists(RemoteService service, String resourceGroup) { Result all = service.run(Select.from("AICore.resourceGroups")); for (Row row : all) { if (resourceGroup.equals(row.get("resourceGroupId"))) { @@ -89,7 +95,7 @@ private boolean resourceGroupExists(CqnService service, String resourceGroup) { return false; } - private void waitForResourceGroupProvisioned(CqnService service, String resourceGroup) { + private void waitForResourceGroupProvisioned(RemoteService service, String resourceGroup) { for (int i = 0; i < 30; i++) { Result all = service.run(Select.from("AICore.resourceGroups")); for (Row row : all) { diff --git a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ConfigurationTest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ConfigurationTest.java index 2b1e5b5..9b7d1af 100644 --- a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ConfigurationTest.java +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ConfigurationTest.java @@ -9,7 +9,7 @@ import com.sap.cds.Row; import com.sap.cds.ql.Insert; import com.sap.cds.ql.Select; -import com.sap.cds.services.cds.CqnService; +import com.sap.cds.services.cds.RemoteService; import java.util.List; import java.util.Map; import org.junit.jupiter.api.Test; @@ -18,7 +18,7 @@ class ConfigurationTest extends BaseIntegrationTest { @Test void readAll_returnsConfigurations() { - CqnService service = getAICoreCqnService(); + RemoteService service = getAICoreCqnService(); String resourceGroup = getAICoreConfig().defaultResourceGroup(); Result result = service.run( @@ -30,7 +30,7 @@ void readAll_returnsConfigurations() { @Test void readAll_filterByScenario() { - CqnService service = getAICoreCqnService(); + RemoteService service = getAICoreCqnService(); String resourceGroup = getAICoreConfig().defaultResourceGroup(); Result result = service.run( @@ -46,7 +46,7 @@ void readAll_filterByScenario() { @Test void create_andReadById() { - CqnService service = getAICoreCqnService(); + RemoteService service = getAICoreCqnService(); String resourceGroup = getAICoreConfig().defaultResourceGroup(); String configName = "itest-config-" + System.currentTimeMillis(); @@ -91,7 +91,7 @@ void create_andReadById() { @Test void create_withParameterBindings_mapsCorrectly() { - CqnService service = getAICoreCqnService(); + RemoteService service = getAICoreCqnService(); String resourceGroup = getAICoreConfig().defaultResourceGroup(); String configName = "itest-params-" + System.currentTimeMillis(); diff --git a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/DeploymentTest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/DeploymentTest.java index d73fd50..c1111dc 100644 --- a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/DeploymentTest.java +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/DeploymentTest.java @@ -10,7 +10,7 @@ import com.sap.cds.Row; import com.sap.cds.ql.Select; import com.sap.cds.ql.Update; -import com.sap.cds.services.cds.CqnService; +import com.sap.cds.services.cds.RemoteService; import java.util.Map; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; @@ -19,7 +19,7 @@ class DeploymentTest extends BaseIntegrationTest { @Test void readAll_returnsDeployments() { - CqnService service = getAICoreCqnService(); + RemoteService service = getAICoreCqnService(); String resourceGroup = getAICoreConfig().defaultResourceGroup(); Result result = service.run( @@ -31,7 +31,7 @@ void readAll_returnsDeployments() { @Test void readSingle_returnsDeploymentDetails() { - CqnService service = getAICoreCqnService(); + RemoteService service = getAICoreCqnService(); String resourceGroup = getAICoreConfig().defaultResourceGroup(); Result all = service.run( @@ -62,7 +62,7 @@ void readSingle_returnsDeploymentDetails() { + "re-enable once test creates its own isolated deployment") @Test void update_targetStatus_stopsRunningDeployment() { - CqnService service = getAICoreCqnService(); + RemoteService service = getAICoreCqnService(); String resourceGroup = getAICoreConfig().defaultResourceGroup(); Result deployments = diff --git a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/MultiTenancyTest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/MultiTenancyTest.java index 2716d06..9d88af9 100644 --- a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/MultiTenancyTest.java +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/MultiTenancyTest.java @@ -7,8 +7,9 @@ import static org.junit.jupiter.api.Assumptions.assumeFalse; import static org.junit.jupiter.api.Assumptions.assumeTrue; -import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.api.ResourceGroupContext; import com.sap.cds.feature.aicore.core.AICoreConfig; +import com.sap.cds.services.cds.RemoteService; import org.junit.jupiter.api.Test; class MultiTenancyTest extends BaseIntegrationTest { @@ -16,13 +17,20 @@ class MultiTenancyTest extends BaseIntegrationTest { @Test void differentTenants_getDifferentResourceGroups() { AICoreConfig config = getAICoreConfig(); - AICoreService service = getAICoreService(); + RemoteService service = getAICoreService(); assumeTrue(config.multiTenancyEnabled(), "Multi-tenancy is not enabled"); String tenantA = "itest-mt-a-" + System.currentTimeMillis(); String tenantB = "itest-mt-b-" + System.currentTimeMillis(); - String rgA = service.resourceGroupForTenant(tenantA); - String rgB = service.resourceGroupForTenant(tenantB); + ResourceGroupContext rgCtxA = ResourceGroupContext.create(); + rgCtxA.setTenantId(tenantA); + service.emit(rgCtxA); + String rgA = rgCtxA.getResult(); + + ResourceGroupContext rgCtxB = ResourceGroupContext.create(); + rgCtxB.setTenantId(tenantB); + service.emit(rgCtxB); + String rgB = rgCtxB.getResult(); assertThat(rgA).isNotEqualTo(rgB); assertThat(rgA).contains(tenantA); @@ -32,21 +40,32 @@ void differentTenants_getDifferentResourceGroups() { @Test void resourceGroupPrefix_appliedCorrectly() { AICoreConfig config = getAICoreConfig(); - AICoreService service = getAICoreService(); + RemoteService service = getAICoreService(); assumeTrue(config.multiTenancyEnabled(), "Multi-tenancy is not enabled"); String tenantA = "itest-prefix-" + System.currentTimeMillis(); - String rg = service.resourceGroupForTenant(tenantA); + ResourceGroupContext rgCtx = ResourceGroupContext.create(); + rgCtx.setTenantId(tenantA); + service.emit(rgCtx); + String rg = rgCtx.getResult(); assertThat(rg).startsWith(config.resourceGroupPrefix()); } @Test void singleTenancy_alwaysReturnsDefault() { AICoreConfig config = getAICoreConfig(); - AICoreService service = getAICoreService(); + RemoteService service = getAICoreService(); assumeFalse(config.multiTenancyEnabled(), "Multi-tenancy is enabled"); - String rg1 = service.resourceGroupForTenant("tenant-x"); - String rg2 = service.resourceGroupForTenant("tenant-y"); + + ResourceGroupContext rgCtx1 = ResourceGroupContext.create(); + rgCtx1.setTenantId("tenant-x"); + service.emit(rgCtx1); + String rg1 = rgCtx1.getResult(); + + ResourceGroupContext rgCtx2 = ResourceGroupContext.create(); + rgCtx2.setTenantId("tenant-y"); + service.emit(rgCtx2); + String rg2 = rgCtx2.getResult(); assertThat(rg1).isEqualTo(config.defaultResourceGroup()); assertThat(rg2).isEqualTo(config.defaultResourceGroup()); diff --git a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ResourceGroupTest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ResourceGroupTest.java index e6ca394..6c00532 100644 --- a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ResourceGroupTest.java +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ResourceGroupTest.java @@ -11,7 +11,7 @@ import com.sap.cds.ql.Delete; import com.sap.cds.ql.Insert; import com.sap.cds.ql.Select; -import com.sap.cds.services.cds.CqnService; +import com.sap.cds.services.cds.RemoteService; import java.util.List; import java.util.Map; import org.junit.jupiter.api.AfterEach; @@ -30,7 +30,7 @@ class ResourceGroupTest extends BaseIntegrationTest { void cleanup() { if (createdResourceGroupId != null) { try { - CqnService service = getAICoreCqnService(); + RemoteService service = getAICoreCqnService(); waitForResourceGroupProvisioned(service, createdResourceGroupId); service.run( Delete.from("AICore.resourceGroups") @@ -44,7 +44,7 @@ void cleanup() { @Test void create_andRead_resourceGroup() { createdResourceGroupId = TEST_RG_PREFIX + System.currentTimeMillis(); - CqnService service = getAICoreCqnService(); + RemoteService service = getAICoreCqnService(); service.run( Insert.into("AICore.resourceGroups") @@ -65,7 +65,7 @@ void create_andRead_resourceGroup() { void create_withTenantLabel_andFilterByTenant() { String tenantId = "itest-tenant-" + System.currentTimeMillis(); createdResourceGroupId = TEST_RG_PREFIX + tenantId; - CqnService service = getAICoreCqnService(); + RemoteService service = getAICoreCqnService(); service.run( Insert.into("AICore.resourceGroups") @@ -82,7 +82,7 @@ void create_withTenantLabel_andFilterByTenant() { @Test void readAll_returnsResourceGroups() { - CqnService service = getAICoreCqnService(); + RemoteService service = getAICoreCqnService(); Result result = service.run(Select.from("AICore.resourceGroups")); assertThat(result.list()).isNotNull(); } @@ -90,7 +90,7 @@ void readAll_returnsResourceGroups() { @Test void create_withLabels() { createdResourceGroupId = TEST_RG_PREFIX + "labels-" + System.currentTimeMillis(); - CqnService service = getAICoreCqnService(); + RemoteService service = getAICoreCqnService(); service.run( Insert.into("AICore.resourceGroups") @@ -119,7 +119,7 @@ void create_withLabels() { @Test void delete_resourceGroup() throws InterruptedException { String rgId = TEST_RG_PREFIX + "del-" + System.currentTimeMillis(); - CqnService service = getAICoreCqnService(); + RemoteService service = getAICoreCqnService(); service.run(Insert.into("AICore.resourceGroups").entry(Map.of("resourceGroupId", rgId))); @@ -135,7 +135,7 @@ void delete_resourceGroup() throws InterruptedException { createdResourceGroupId = null; // already deleted } - private void waitForResourceGroupProvisioned(CqnService service, String rgId) + private void waitForResourceGroupProvisioned(RemoteService service, String rgId) throws InterruptedException { for (int i = 0; i < 30; i++) { Result result = From 0c50d816059e226b27346e4ffa0ef79a01d1f883 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Wed, 17 Jun 2026 13:58:32 +0200 Subject: [PATCH 33/34] update sample --- .../handlers/AICoreShowcaseHandler.java | 48 +++++++++++++++---- 1 file changed, 38 insertions(+), 10 deletions(-) diff --git a/samples/bookshop/srv/src/main/java/customer/bookshop/handlers/AICoreShowcaseHandler.java b/samples/bookshop/srv/src/main/java/customer/bookshop/handlers/AICoreShowcaseHandler.java index 1cff6be..d2cfaf6 100644 --- a/samples/bookshop/srv/src/main/java/customer/bookshop/handlers/AICoreShowcaseHandler.java +++ b/samples/bookshop/srv/src/main/java/customer/bookshop/handlers/AICoreShowcaseHandler.java @@ -2,7 +2,10 @@ import com.sap.cds.CdsData; import com.sap.cds.Result; -import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.api.AICore; +import com.sap.cds.feature.aicore.api.DeploymentIdContext; +import com.sap.cds.feature.aicore.api.InferenceClientContext; +import com.sap.cds.feature.aicore.api.ResourceGroupContext; import com.sap.cds.feature.recommendation.api.RptInferenceClient; import com.sap.cds.feature.recommendation.api.RptModelSpec; import com.sap.cds.ql.Insert; @@ -11,6 +14,7 @@ import com.sap.cds.services.EventContext; import com.sap.cds.services.cds.CdsReadEventContext; import com.sap.cds.services.cds.CqnService; +import com.sap.cds.services.cds.RemoteService; import com.sap.cds.services.handler.EventHandler; import com.sap.cds.services.handler.annotations.On; import com.sap.cds.services.handler.annotations.ServiceName; @@ -28,8 +32,8 @@ public class AICoreShowcaseHandler implements EventHandler { @Autowired private CdsRuntime runtime; - private AICoreService getAICoreService() { - return runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_NAME); + private RemoteService getAICoreService() { + return runtime.getServiceCatalog().getService(RemoteService.class, AICore.SERVICE_NAME); } // This handler is NOT required - the plugin automatically delegates reads on projections @@ -42,14 +46,20 @@ public void onReadConfigurations(CdsReadEventContext context) { @On(event = "setupTenantResources") public void onSetupTenantResources(EventContext context) { - String rgId = getAICoreService().resourceGroup(); + RemoteService service = getAICoreService(); + ResourceGroupContext rgCtx = ResourceGroupContext.create(); + service.emit(rgCtx); + String rgId = rgCtx.getResult(); context.put("result", rgId); context.setCompleted(); } @On(event = "getMyResourceGroup") public void onGetMyResourceGroup(EventContext context) { - String rgId = getAICoreService().resourceGroup(); + RemoteService service = getAICoreService(); + ResourceGroupContext rgCtx = ResourceGroupContext.create(); + service.emit(rgCtx); + String rgId = rgCtx.getResult(); context.put("result", rgId); context.setCompleted(); } @@ -57,7 +67,12 @@ public void onGetMyResourceGroup(EventContext context) { @On(event = "provisionRpt1") public void onProvisionRpt1(EventContext context) { String resourceGroupId = (String) context.get("resourceGroupId"); - String deploymentId = getAICoreService().deploymentId(resourceGroupId, RptModelSpec.rpt1()); + RemoteService service = getAICoreService(); + DeploymentIdContext depCtx = DeploymentIdContext.create(); + depCtx.setResourceGroupId(resourceGroupId); + depCtx.setSpec(RptModelSpec.rpt1()); + service.emit(depCtx); + String deploymentId = depCtx.getResult(); context.put("result", deploymentId); context.setCompleted(); } @@ -126,10 +141,23 @@ public void onPredictCategory(EventContext context) { Map.of( "ID", "ctx-5", "name", "Blender", "price", "89.99", "category", "Appliances"))); - AICoreService service = getAICoreService(); - String rg = service.resourceGroup(); - String deploymentId = service.deploymentId(rg, RptModelSpec.rpt1()); - RptInferenceClient client = new RptInferenceClient(service.inferenceClient(rg, deploymentId)); + RemoteService service = getAICoreService(); + + ResourceGroupContext rgCtx = ResourceGroupContext.create(); + service.emit(rgCtx); + String rg = rgCtx.getResult(); + + DeploymentIdContext depCtx = DeploymentIdContext.create(); + depCtx.setResourceGroupId(rg); + depCtx.setSpec(RptModelSpec.rpt1()); + service.emit(depCtx); + String deploymentId = depCtx.getResult(); + + InferenceClientContext infCtx = InferenceClientContext.create(); + infCtx.setResourceGroupId(rg); + infCtx.setDeploymentId(deploymentId); + service.emit(infCtx); + RptInferenceClient client = new RptInferenceClient(infCtx.getResult()); List> results = new ArrayList<>(); for (Map product : products) { From 91a258068b79548c317fca73fe1bfbb8a80c1f8a Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Wed, 17 Jun 2026 14:07:22 +0200 Subject: [PATCH 34/34] fix sample --- samples/bookshop/.cdsrc.json | 4 ++-- samples/bookshop/srv/ai-core-service.cds | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/samples/bookshop/.cdsrc.json b/samples/bookshop/.cdsrc.json index 94e9eda..f419d38 100644 --- a/samples/bookshop/.cdsrc.json +++ b/samples/bookshop/.cdsrc.json @@ -2,13 +2,13 @@ "requires": { "db": "hana", "AICore": { - "model": "com.sap.cds/ai" + "model": "@cap-js/ai/srv/AICoreService" }, "[production]": { "auth": "xsuaa" } }, "cdsc": { - "moduleLookupDirectories": ["node_modules/", "target/cds/"] + "moduleLookupDirectories": ["node_modules/"] } } diff --git a/samples/bookshop/srv/ai-core-service.cds b/samples/bookshop/srv/ai-core-service.cds index 7124cc4..1fc24c8 100644 --- a/samples/bookshop/srv/ai-core-service.cds +++ b/samples/bookshop/srv/ai-core-service.cds @@ -1,4 +1,4 @@ -using { AICore } from 'com.sap.cds/ai'; +using { AICore } from '@cap-js/ai/srv/AICoreService'; service AICoreShowcaseService @(requires: 'any') {