From e217a96c3e9aa1a75b012c9ab073ad505bf454bd Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Wed, 27 May 2026 12:04:35 +0200 Subject: [PATCH 1/6] fix: use idiomatic patterns for error messages and stream collection - Replace string concatenation in ServiceException messages with SLF4J-style {} placeholders in AICoreSetupHandler (3 locations) - Replace Collectors.toList() with .toList() in AbstractCrudHandler - Replace new ArrayList<>() with List.of() for null case - Remove unused imports (ArrayList, Collectors) Closes #32 --- .../cds/feature/aicore/core/AICoreSetupHandler.java | 10 +++++++--- .../aicore/core/handler/AbstractCrudHandler.java | 6 ++---- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreSetupHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreSetupHandler.java index e605e15..fddd340 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreSetupHandler.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreSetupHandler.java @@ -41,7 +41,8 @@ public void afterSubscribe(SubscribeEventContext context) { } catch (Exception e) { throw new ServiceException( ErrorStatuses.SERVER_ERROR, - "Failed to create AI Core resources for tenant: " + tenantId, + "Failed to create AI Core resources for tenant: {}", + tenantId, e); } } @@ -79,7 +80,9 @@ private void deleteResourceGroupForTenant(String tenantId) { } throw new ServiceException( ErrorStatuses.SERVER_ERROR, - "Failed to delete AI Core resource group " + resourceGroupId + " for tenant " + tenantId, + "Failed to delete AI Core resource group {} for tenant {}", + resourceGroupId, + tenantId, e); } } @@ -103,7 +106,8 @@ private String resolveResourceGroupId(String tenantId) { } catch (OpenApiRequestException e) { throw new ServiceException( ErrorStatuses.SERVER_ERROR, - "Failed to look up AI Core resource group for tenant " + tenantId, + "Failed to look up AI Core resource group for tenant {}", + tenantId, e); } List resources = result.getResources(); diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AbstractCrudHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AbstractCrudHandler.java index 3793e76..47f3858 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AbstractCrudHandler.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AbstractCrudHandler.java @@ -5,12 +5,10 @@ import com.sap.cds.feature.aicore.core.AICoreServiceImpl; import com.sap.cds.services.handler.EventHandler; -import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.function.Function; -import java.util.stream.Collectors; abstract class AbstractCrudHandler implements EventHandler { @@ -34,7 +32,7 @@ protected static Map merge(Map keys, Map List mapResources(List resources, Function mapper) { - if (resources == null) return new ArrayList<>(); - return resources.stream().map(mapper).collect(Collectors.toList()); + if (resources == null) return List.of(); + return resources.stream().map(mapper).toList(); } } From c9953056ad58941153b8fe0297ec25d4cd084d44 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Wed, 27 May 2026 12:13:45 +0200 Subject: [PATCH 2/6] refactor: use cds-maven-plugin generated interfaces for type-safe entity access Replace manual AICoreElements constants class with generated typed interfaces from cds-maven-plugin (following cds-feature-attachments pattern): - Add cds-maven-plugin generate goal to cds-feature-ai-core/pom.xml - Generated interfaces: Deployments, Configurations, ResourceGroups, BckndResourceGroupLabel (with constants + typed getters/setters) - Update all handlers to use generated constants (e.g., Deployments.ID) and typed factory methods (e.g., Deployments.create(), ResourceGroups.create(id)) - Remove manual AICoreElements.java (replaced by code generation) - DeploymentHandler: use Deployments.create() with typed setters instead of 17-parameter buildDeploymentData method Closes #33 --- cds-feature-ai-core/pom.xml | 37 ++++ .../aicore/core/handler/ActionHandler.java | 4 +- .../core/handler/ConfigurationHandler.java | 55 +++--- .../core/handler/DeploymentHandler.java | 159 +++++++----------- .../core/handler/ResourceGroupHandler.java | 51 +++--- 5 files changed, 158 insertions(+), 148 deletions(-) diff --git a/cds-feature-ai-core/pom.xml b/cds-feature-ai-core/pom.xml index 7658c88..45109b8 100644 --- a/cds-feature-ai-core/pom.xml +++ b/cds-feature-ai-core/pom.xml @@ -51,6 +51,43 @@ ${project.artifactId} + + com.sap.cds + cds-maven-plugin + + + cds.install-node + + install-node + + + + cds.build + + cds + + + ./src/main/resources/cds/com.sap.cds/ai + + build --for java --src ./ --dest ../../../../../../gen/srv + + + + + cds.generate + + generate + + + com.sap.cds.feature.aicore.generated.cds4j + ${project.basedir}/src/gen/srv/src/main/resources/edmx/csn.json + + AICore.** + + + + + org.jacoco jacoco-maven-plugin 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 b811c96..611cd23 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 @@ -3,6 +3,8 @@ */ package com.sap.cds.feature.aicore.core.handler; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.Deployments; + import com.sap.ai.sdk.core.client.DeploymentApi; import com.sap.ai.sdk.core.model.AiDeploymentModificationRequest; import com.sap.ai.sdk.core.model.AiDeploymentTargetStatus; @@ -40,7 +42,7 @@ public void onResourceGroupForTenant(EventContext context) { @On(event = "stop", entity = AICoreService.DEPLOYMENTS) public void onStop(EventContext context) { Map keys = asMap(context.get("keys")); - String deploymentId = (String) keys.get("id"); + String deploymentId = (String) keys.get(Deployments.ID); String resourceGroupId = resolveResourceGroup(keys); DeploymentApi api = service.getDeploymentApi(); 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 d3fb8d9..69861aa 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 @@ -11,6 +11,9 @@ import com.sap.cds.CdsData; import com.sap.cds.feature.aicore.core.AICoreService; import com.sap.cds.feature.aicore.core.AICoreServiceImpl; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.Configurations; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.ParameterArgumentBinding; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.ResourceGroups; import com.sap.cds.ql.cqn.AnalysisResult; import com.sap.cds.ql.cqn.CqnAnalyzer; import com.sap.cds.ql.cqn.CqnInsert; @@ -54,16 +57,16 @@ public void onRead(CdsReadEventContext context) { keys, values); - String id = (String) keys.get("id"); + String id = (String) keys.get(Configurations.ID); if (id != null) { AiConfiguration config = configurationApi.get(resourceGroupId, id); - context.setResult(List.of(toMap(config, resourceGroupId))); + context.setResult(List.of(toConfigurations(config, resourceGroupId))); } else { - String scenarioId = (String) values.get("scenarioId"); + String scenarioId = (String) values.get(Configurations.SCENARIO_ID); AiConfigurationList result = configurationApi.query(resourceGroupId, scenarioId, null, null, null, null, null, null); List> results = - mapResources(result.getResources(), c -> toMap(c, resourceGroupId)); + mapResources(result.getResources(), c -> toConfigurations(c, resourceGroupId)); logger.debug("ConfigurationApi.query returned {} resources", results.size()); context.setResult(results); } @@ -77,9 +80,9 @@ public void onCreate(CdsCreateEventContext context) { for (Map entry : entries) { String resourceGroupId = resolveResourceGroup(entry); - String name = (String) entry.get("name"); - String executableId = (String) entry.get("executableId"); - String scenarioId = (String) entry.get("scenarioId"); + String name = (String) entry.get(Configurations.NAME); + String executableId = (String) entry.get(Configurations.EXECUTABLE_ID); + String scenarioId = (String) entry.get(Configurations.SCENARIO_ID); AiConfigurationBaseData request = AiConfigurationBaseData.create() @@ -89,22 +92,22 @@ public void onCreate(CdsCreateEventContext context) { @SuppressWarnings("unchecked") List> paramBindings = - (List>) entry.get("parameterBindings"); + (List>) entry.get(Configurations.PARAMETER_BINDINGS); if (paramBindings != null) { List sdkBindings = paramBindings.stream() .map( p -> AiParameterArgumentBinding.create() - .key((String) p.get("key")) - .value((String) p.get("value"))) + .key((String) p.get(ParameterArgumentBinding.KEY)) + .value((String) p.get(ParameterArgumentBinding.VALUE))) .toList(); request.parameterBindings(sdkBindings); } var response = configurationApi.create(resourceGroupId, request); CdsData result = CdsData.create(entry); - result.put("id", response.getId()); + result.put(Configurations.ID, response.getId()); results.add(result); logger.debug( "Created configuration {} in resource group {}", response.getId(), resourceGroupId); @@ -112,25 +115,25 @@ public void onCreate(CdsCreateEventContext context) { context.setResult(results); } - private CdsData toMap(AiConfiguration config, String resourceGroupId) { - CdsData data = CdsData.create(); - data.put("id", config.getId()); - data.put("name", config.getName()); - data.put("executableId", config.getExecutableId()); - data.put("scenarioId", config.getScenarioId()); - data.put("createdAt", config.getCreatedAt()); + private Configurations toConfigurations(AiConfiguration config, String resourceGroupId) { + Configurations data = Configurations.create(); + data.setId(config.getId()); + data.setName(config.getName()); + data.setExecutableId(config.getExecutableId()); + data.setScenarioId(config.getScenarioId()); + data.put(Configurations.CREATED_AT, config.getCreatedAt()); if (config.getParameterBindings() != null) { List bindings = config.getParameterBindings().stream() .map( b -> { - CdsData bm = CdsData.create(); - bm.put("key", b.getKey()); - bm.put("value", b.getValue()); - return bm; + var bm = ParameterArgumentBinding.create(); + bm.setKey(b.getKey()); + bm.setValue(b.getValue()); + return (CdsData) bm; }) .toList(); - data.put("parameterBindings", bindings); + data.put(Configurations.PARAMETER_BINDINGS, bindings); } if (config.getInputArtifactBindings() != null) { List bindings = @@ -138,14 +141,14 @@ private CdsData toMap(AiConfiguration config, String resourceGroupId) { .map( b -> { CdsData bm = CdsData.create(); - bm.put("key", b.getKey()); + bm.put(ParameterArgumentBinding.KEY, b.getKey()); bm.put("artifactId", b.getArtifactId()); return bm; }) .toList(); - data.put("inputArtifactBindings", bindings); + data.put(Configurations.INPUT_ARTIFACT_BINDINGS, bindings); } - data.putPath("resourceGroup.resourceGroupId", resourceGroupId); + data.setResourceGroup(ResourceGroups.create(resourceGroupId)); return data; } } 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 f1e6c76..2d3052a 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 @@ -13,6 +13,8 @@ import com.sap.cds.CdsData; import com.sap.cds.feature.aicore.core.AICoreService; import com.sap.cds.feature.aicore.core.AICoreServiceImpl; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.Deployments; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.ResourceGroups; import com.sap.cds.ql.cqn.AnalysisResult; import com.sap.cds.ql.cqn.CqnAnalyzer; import com.sap.cds.ql.cqn.CqnDelete; @@ -57,14 +59,15 @@ public void onRead(CdsReadEventContext context) { String resourceGroupId = resolveResourceGroup(merge(keys, values)); - String id = (String) keys.get("id"); + String id = (String) keys.get(Deployments.ID); if (id != null) { AiDeploymentResponseWithDetails deployment = deploymentApi.get(resourceGroupId, id); - context.setResult(List.of(toMap(deployment, resourceGroupId))); + context.setResult(List.of(toDeployments(deployment, resourceGroupId))); } else { AiDeploymentList result = deploymentApi.query(resourceGroupId, null, null, null, null, null, null, null); - context.setResult(mapResources(result.getResources(), d -> toMap(d, resourceGroupId))); + context.setResult( + mapResources(result.getResources(), d -> toDeployments(d, resourceGroupId))); } } @@ -76,19 +79,19 @@ public void onCreate(CdsCreateEventContext context) { for (Map entry : entries) { String resourceGroupId = resolveResourceGroup(entry); - String configurationId = (String) entry.get("configurationId"); + String configurationId = (String) entry.get(Deployments.CONFIGURATION_ID); AiDeploymentCreationRequest request = AiDeploymentCreationRequest.create().configurationId(configurationId); - if (entry.containsKey("ttl")) { - request.ttl((String) entry.get("ttl")); + if (entry.containsKey(Deployments.TTL)) { + request.ttl((String) entry.get(Deployments.TTL)); } var response = deploymentApi.create(resourceGroupId, request); CdsData result = CdsData.create(entry); - result.put("id", response.getId()); - result.put("status", response.getStatus().getValue()); + result.put(Deployments.ID, response.getId()); + result.put(Deployments.STATUS, response.getStatus().getValue()); results.add(result); logger.debug("Created deployment {} in resource group {}", response.getId(), resourceGroupId); } @@ -103,7 +106,8 @@ public void onUpdate(CdsUpdateEventContext context) { throw new ServiceException(ErrorStatuses.BAD_REQUEST, "No update payload provided"); } Map data = entries.get(0); - if (!data.containsKey("targetStatus") && !data.containsKey("configurationId")) { + if (!data.containsKey(Deployments.TARGET_STATUS) + && !data.containsKey(Deployments.CONFIGURATION_ID)) { throw new ServiceException( ErrorStatuses.BAD_REQUEST, "Update payload must contain 'targetStatus' or 'configurationId'"); @@ -113,17 +117,17 @@ public void onUpdate(CdsUpdateEventContext context) { CqnAnalyzer analyzer = CqnAnalyzer.create(model); Map keys = analyzer.analyze(update).targetKeys(); - String deploymentId = (String) keys.get("id"); + String deploymentId = (String) keys.get(Deployments.ID); String resourceGroupId = resolveResourceGroup(merge(keys, data)); AiDeploymentModificationRequest modRequest = AiDeploymentModificationRequest.create(); - if (data.containsKey("targetStatus")) { - String targetStatus = (String) data.get("targetStatus"); + if (data.containsKey(Deployments.TARGET_STATUS)) { + String targetStatus = (String) data.get(Deployments.TARGET_STATUS); modRequest.targetStatus(AiDeploymentTargetStatus.fromValue(targetStatus)); } - if (data.containsKey("configurationId")) { - modRequest.configurationId((String) data.get("configurationId")); + if (data.containsKey(Deployments.CONFIGURATION_ID)) { + modRequest.configurationId((String) data.get(Deployments.CONFIGURATION_ID)); } deploymentApi.modify(resourceGroupId, deploymentId, modRequest); @@ -138,7 +142,7 @@ public void onDelete(CdsDeleteEventContext context) { CqnAnalyzer analyzer = CqnAnalyzer.create(model); Map keys = analyzer.analyze(delete).targetKeys(); - String deploymentId = (String) keys.get("id"); + String deploymentId = (String) keys.get(Deployments.ID); String resourceGroupId = resolveResourceGroup(keys); deploymentApi.delete(resourceGroupId, deploymentId); @@ -147,90 +151,51 @@ public void onDelete(CdsDeleteEventContext context) { } // CPD-OFF - SDK types AiDeploymentResponseWithDetails and AiDeployment share no common interface - private CdsData toMap(AiDeploymentResponseWithDetails d, String resourceGroupId) { - return buildDeploymentData( - d.getId(), - d.getDeploymentUrl(), - d.getConfigurationId(), - d.getConfigurationName(), - d.getExecutableId(), - d.getScenarioId(), - d.getStatus().getValue(), - d.getStatusMessage(), - d.getTargetStatus().getValue(), - d.getLastOperation() != null ? d.getLastOperation().getValue() : null, - d.getLatestRunningConfigurationId(), - d.getTtl(), - d.getCreatedAt(), - d.getModifiedAt(), - d.getSubmissionTime(), - d.getStartTime(), - d.getCompletionTime(), - resourceGroupId); - } - - private CdsData toMap(AiDeployment d, String resourceGroupId) { - return buildDeploymentData( - d.getId(), - d.getDeploymentUrl(), - d.getConfigurationId(), - d.getConfigurationName(), - d.getExecutableId(), - d.getScenarioId(), - d.getStatus().getValue(), - d.getStatusMessage(), - d.getTargetStatus().getValue(), - d.getLastOperation() != null ? d.getLastOperation().getValue() : null, - d.getLatestRunningConfigurationId(), - d.getTtl(), - d.getCreatedAt(), - d.getModifiedAt(), - d.getSubmissionTime(), - d.getStartTime(), - d.getCompletionTime(), - resourceGroupId); + private static Deployments toDeployments( + AiDeploymentResponseWithDetails d, String resourceGroupId) { + Deployments data = Deployments.create(); + data.setId(d.getId()); + data.setDeploymentUrl(d.getDeploymentUrl()); + data.setConfigurationId(d.getConfigurationId()); + data.setConfigurationName(d.getConfigurationName()); + data.setExecutableId(d.getExecutableId()); + data.setScenarioId(d.getScenarioId()); + data.setStatus(d.getStatus().getValue()); + data.setStatusMessage(d.getStatusMessage()); + data.setTargetStatus(d.getTargetStatus().getValue()); + data.setLastOperation(d.getLastOperation() != null ? d.getLastOperation().getValue() : null); + data.setLatestRunningConfigurationId(d.getLatestRunningConfigurationId()); + data.setTtl(d.getTtl()); + data.put(Deployments.CREATED_AT, d.getCreatedAt()); + data.put(Deployments.MODIFIED_AT, d.getModifiedAt()); + data.put(Deployments.SUBMISSION_TIME, d.getSubmissionTime()); + data.put(Deployments.START_TIME, d.getStartTime()); + data.put(Deployments.COMPLETION_TIME, d.getCompletionTime()); + data.setResourceGroup(ResourceGroups.create(resourceGroupId)); + return data; } - // CPD-ON - - private static CdsData buildDeploymentData( - String id, - String deploymentUrl, - String configurationId, - String configurationName, - String executableId, - String scenarioId, - String status, - String statusMessage, - String targetStatus, - String lastOperation, - String latestRunningConfigurationId, - String ttl, - Object createdAt, - Object modifiedAt, - Object submissionTime, - Object startTime, - Object completionTime, - String resourceGroupId) { - CdsData data = CdsData.create(); - data.put("id", id); - data.put("deploymentUrl", deploymentUrl); - data.put("configurationId", configurationId); - data.put("configurationName", configurationName); - data.put("executableId", executableId); - data.put("scenarioId", scenarioId); - data.put("status", status); - data.put("statusMessage", statusMessage); - data.put("targetStatus", targetStatus); - data.put("lastOperation", lastOperation); - data.put("latestRunningConfigurationId", latestRunningConfigurationId); - data.put("ttl", ttl); - data.put("createdAt", createdAt); - data.put("modifiedAt", modifiedAt); - data.put("submissionTime", submissionTime); - data.put("startTime", startTime); - data.put("completionTime", completionTime); - data.putPath("resourceGroup.resourceGroupId", resourceGroupId); + private static Deployments toDeployments(AiDeployment d, String resourceGroupId) { + Deployments data = Deployments.create(); + data.setId(d.getId()); + data.setDeploymentUrl(d.getDeploymentUrl()); + data.setConfigurationId(d.getConfigurationId()); + data.setConfigurationName(d.getConfigurationName()); + data.setExecutableId(d.getExecutableId()); + data.setScenarioId(d.getScenarioId()); + data.setStatus(d.getStatus().getValue()); + data.setStatusMessage(d.getStatusMessage()); + data.setTargetStatus(d.getTargetStatus().getValue()); + data.setLastOperation(d.getLastOperation() != null ? d.getLastOperation().getValue() : null); + data.setLatestRunningConfigurationId(d.getLatestRunningConfigurationId()); + data.setTtl(d.getTtl()); + data.put(Deployments.CREATED_AT, d.getCreatedAt()); + data.put(Deployments.MODIFIED_AT, d.getModifiedAt()); + data.put(Deployments.SUBMISSION_TIME, d.getSubmissionTime()); + data.put(Deployments.START_TIME, d.getStartTime()); + data.put(Deployments.COMPLETION_TIME, d.getCompletionTime()); + data.setResourceGroup(ResourceGroups.create(resourceGroupId)); return data; } + // CPD-ON } 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 1037b68..685089e 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 @@ -12,6 +12,7 @@ import com.sap.cds.CdsData; import com.sap.cds.feature.aicore.core.AICoreService; import com.sap.cds.feature.aicore.core.AICoreServiceImpl; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.ResourceGroups; import com.sap.cds.ql.cqn.AnalysisResult; import com.sap.cds.ql.cqn.CqnAnalyzer; import com.sap.cds.ql.cqn.CqnDelete; @@ -53,9 +54,9 @@ public void onRead(CdsReadEventContext context) { Map keys = analysis.targetKeys(); Map values = analysis.targetValues(); - String resourceGroupId = (String) keys.get("resourceGroupId"); + String resourceGroupId = (String) keys.get(ResourceGroups.RESOURCE_GROUP_ID); if (resourceGroupId == null) { - resourceGroupId = (String) values.get("resourceGroupId"); + resourceGroupId = (String) values.get(ResourceGroups.RESOURCE_GROUP_ID); } if (resourceGroupId != null) { @@ -63,8 +64,8 @@ public void onRead(CdsReadEventContext context) { context.setResult(List.of(toMap(rg))); } else { List labelSelector = null; - if (values.containsKey("tenantId")) { - String tenantId = (String) values.get("tenantId"); + if (values.containsKey(ResourceGroups.TENANT_ID)) { + String tenantId = (String) values.get(ResourceGroups.TENANT_ID); labelSelector = List.of(AICoreServiceImpl.TENANT_LABEL_KEY + "=" + tenantId); } BckndResourceGroupList result = @@ -80,12 +81,13 @@ public void onCreate(CdsCreateEventContext context) { List> results = new ArrayList<>(); for (Map entry : entries) { - String resourceGroupId = (String) entry.get("resourceGroupId"); + String resourceGroupId = (String) entry.get(ResourceGroups.RESOURCE_GROUP_ID); BckndResourceGroupsPostRequest request = BckndResourceGroupsPostRequest.create().resourceGroupId(resourceGroupId); @SuppressWarnings("unchecked") - List> labels = (List>) entry.get("labels"); + List> labels = + (List>) entry.get(ResourceGroups.LABELS); List mergedLabels = new ArrayList<>(); // User-supplied labels take precedence: if they include the tenant label key, we skip @@ -95,8 +97,8 @@ public void onCreate(CdsCreateEventContext context) { && labels.stream() .anyMatch(l -> AICoreServiceImpl.TENANT_LABEL_KEY.equals(l.get("key"))); - if (entry.containsKey("tenantId") && !userSuppliedTenantLabel) { - String tenantId = (String) entry.get("tenantId"); + if (entry.containsKey(ResourceGroups.TENANT_ID) && !userSuppliedTenantLabel) { + String tenantId = (String) entry.get(ResourceGroups.TENANT_ID); mergedLabels.add( BckndResourceGroupLabel.create() .key(AICoreServiceImpl.TENANT_LABEL_KEY) @@ -131,7 +133,7 @@ public void onUpdate(CdsUpdateEventContext context) { BckndResourceGroupPatchRequest patchRequest = BckndResourceGroupPatchRequest.create(); @SuppressWarnings("unchecked") - List> labels = (List>) data.get("labels"); + List> labels = (List>) data.get(ResourceGroups.LABELS); if (labels != null) { patchRequest.labels(toSdkLabels(labels)); } @@ -155,11 +157,11 @@ public void onDelete(CdsDeleteEventContext context) { } private String resolveResourceGroupId(Map keys) { - if (keys.containsKey("resourceGroupId")) { - return (String) keys.get("resourceGroupId"); + if (keys.containsKey(ResourceGroups.RESOURCE_GROUP_ID)) { + return (String) keys.get(ResourceGroups.RESOURCE_GROUP_ID); } - if (keys.containsKey("tenantId")) { - return service.resourceGroupForTenant((String) keys.get("tenantId")); + if (keys.containsKey(ResourceGroups.TENANT_ID)) { + return service.resourceGroupForTenant((String) keys.get(ResourceGroups.TENANT_ID)); } return service.getDefaultResourceGroup(); } @@ -174,24 +176,25 @@ private static List toSdkLabels(List labels = new ArrayList<>(rg.getLabels().size()); for (BckndResourceGroupLabel l : rg.getLabels()) { - CdsData lm = CdsData.create(); - lm.put("key", l.getKey()); - lm.put("value", l.getValue()); + var lm = + com.sap.cds.feature.aicore.generated.cds4j.aicore.BckndResourceGroupLabel.create(); + lm.setKey(l.getKey()); + lm.setValue(l.getValue()); labels.add(lm); if (AICoreServiceImpl.TENANT_LABEL_KEY.equals(l.getKey())) { - data.put("tenantId", l.getValue()); + data.setTenantId(l.getValue()); } } - data.put("labels", labels); + data.put(ResourceGroups.LABELS, labels); } return data; } From cf971b41daf0974d114e4616bc2bc79b87ee14de Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Wed, 27 May 2026 12:18:45 +0200 Subject: [PATCH 3/6] refactor: slim AICoreService interface to public API surface Remove implementation-detail methods from AICoreService interface: - getDefaultResourceGroup() - getResourceGroupPrefix() - getTenantResourceGroupCache() - getResourceGroupDeploymentCache() - clearTenantCache() - resolveResourceGroupFromKeys() These methods remain public on AICoreServiceImpl and MockAICoreServiceImpl but are no longer part of the service contract. Only domain-level API (resourceGroupForTenant, deploymentId, inferenceClient, isMultiTenancyEnabled) and getRetry() (cross-module consumer) remain on the interface. Closes #34 --- .../sap/cds/feature/aicore/core/AICoreService.java | 13 ------------- .../cds/feature/aicore/core/AICoreServiceImpl.java | 6 ------ .../feature/aicore/core/MockAICoreServiceImpl.java | 6 ------ 3 files changed, 25 deletions(-) diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreService.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreService.java index 138076e..91a3f3d 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreService.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreService.java @@ -6,7 +6,6 @@ import com.sap.cds.services.cds.CqnService; import com.sap.cloud.sdk.services.openapi.apache.apiclient.ApiClient; import io.github.resilience4j.retry.Retry; -import java.util.Map; public interface AICoreService extends CqnService { @@ -24,16 +23,4 @@ public interface AICoreService extends CqnService { boolean isMultiTenancyEnabled(); Retry getRetry(); - - String getDefaultResourceGroup(); - - String getResourceGroupPrefix(); - - Map getTenantResourceGroupCache(); - - Map getResourceGroupDeploymentCache(); - - void clearTenantCache(String tenantId); - - String resolveResourceGroupFromKeys(Map keys); } 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 index 2c0d4b6..f0897cf 100644 --- 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 @@ -156,22 +156,18 @@ public Retry getRetry() { return retry; } - @Override public String getDefaultResourceGroup() { return defaultResourceGroup; } - @Override public String getResourceGroupPrefix() { return resourceGroupPrefix; } - @Override public Map getTenantResourceGroupCache() { return tenantResourceGroupCache.asMap(); } - @Override public Map getResourceGroupDeploymentCache() { return resourceGroupDeploymentCache.asMap(); } @@ -192,7 +188,6 @@ public ResourceGroupApi getResourceGroupApi() { return resourceGroupApi; } - @Override public String resolveResourceGroupFromKeys(Map keys) { if (keys.containsKey("resourceGroup_resourceGroupId")) { return (String) keys.get("resourceGroup_resourceGroupId"); @@ -205,7 +200,6 @@ public String resolveResourceGroupFromKeys(Map keys) { return resourceGroupForTenant(tenantId); } - @Override public void clearTenantCache(String tenantId) { String resourceGroupId = tenantResourceGroupCache.asMap().remove(tenantId); if (resourceGroupId != null) { diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImpl.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImpl.java index c13a197..637b964 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImpl.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImpl.java @@ -68,27 +68,22 @@ public Retry getRetry() { return retry; } - @Override public String getDefaultResourceGroup() { return defaultResourceGroup; } - @Override public String getResourceGroupPrefix() { return resourceGroupPrefix; } - @Override public Map getTenantResourceGroupCache() { return tenantResourceGroupCache; } - @Override public Map getResourceGroupDeploymentCache() { return resourceGroupDeploymentCache; } - @Override public void clearTenantCache(String tenantId) { String resourceGroupId = tenantResourceGroupCache.remove(tenantId); if (resourceGroupId != null) { @@ -99,7 +94,6 @@ public void clearTenantCache(String tenantId) { } } - @Override public String resolveResourceGroupFromKeys(Map keys) { if (keys.containsKey("resourceGroup_resourceGroupId")) { return (String) keys.get("resourceGroup_resourceGroupId"); From 7cf0ce90c3e1d7a2ee407dce755e7c356b6ea8b3 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Wed, 27 May 2026 12:24:06 +0200 Subject: [PATCH 4/6] refactor: decompose FioriRecommendationHandler into focused helpers Extract two helper classes from the 400-line handler: - RecommendationContextBuilder: entity analysis, prediction element discovery, context query building, synthetic key computation, and row assembly - RecommendationResultParser: prediction value type coercion, text path resolution from annotations, description DB lookups, and final recommendations map assembly The handler is now a slim orchestrator (~110 lines) that delegates complex logic to the helpers. Also replace unbounded ConcurrentHashMap.newKeySet() with a bounded Caffeine cache (max 10,000 entries) for the entity negative-result cache. Closes #35 --- .../FioriRecommendationHandler.java | 330 ++---------------- .../RecommendationContextBuilder.java | 188 ++++++++++ .../RecommendationResultParser.java | 182 ++++++++++ 3 files changed, 400 insertions(+), 300 deletions(-) create mode 100644 cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationContextBuilder.java create mode 100644 cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationResultParser.java 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 46aef51..2af07ba 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 @@ -3,17 +3,10 @@ */ package com.sap.cds.feature.recommendation; -import static com.sap.cds.reflect.CdsAnnotatable.byAnnotation; - +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; import com.sap.cds.CdsData; -import com.sap.cds.Result; import com.sap.cds.feature.aicore.core.AICoreService; -import com.sap.cds.ql.CQL; -import com.sap.cds.ql.Select; -import com.sap.cds.reflect.CdsAssociationType; -import com.sap.cds.reflect.CdsBaseType; -import com.sap.cds.reflect.CdsElement; -import com.sap.cds.reflect.CdsSimpleType; import com.sap.cds.reflect.CdsStructuredType; import com.sap.cds.services.cds.ApplicationService; import com.sap.cds.services.cds.CdsReadEventContext; @@ -23,57 +16,23 @@ import com.sap.cds.services.handler.annotations.ServiceName; import com.sap.cds.services.persistence.PersistenceService; import com.sap.cds.services.utils.DraftUtils; -import java.math.BigDecimal; import java.util.ArrayList; -import java.util.EnumSet; -import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @ServiceName(value = "*", type = ApplicationService.class) class FioriRecommendationHandler implements EventHandler { - private final AICoreService aiCoreService; - private final RecommendationClientResolver clientResolver; - private final Set entitiesWithoutPredictions = ConcurrentHashMap.newKeySet(); private static final Logger logger = LoggerFactory.getLogger(FioriRecommendationHandler.class); - private static final String VALUE_LIST_ANNOTATION = "@Common.ValueList"; - private static final String VALUE_LIST_WITH_FIXED_VALUES_ANNOTATION = - "@Common.ValueListWithFixedValues"; private static final int DEFAULT_CONTEXT_ROW_LIMIT = 2000; - private static final String SYNTHETIC_KEY_COLUMN = "SAP_RECOMMENDATIONS_ID"; - private static final Set SUPPORTED_CONTEXT_TYPES = - EnumSet.of( - CdsBaseType.STRING, - CdsBaseType.LARGE_STRING, - CdsBaseType.UUID, - CdsBaseType.BOOLEAN, - CdsBaseType.INTEGER, - CdsBaseType.UINT8, - CdsBaseType.INT16, - CdsBaseType.INT32, - CdsBaseType.INT64, - CdsBaseType.INTEGER64, - CdsBaseType.DECIMAL, - CdsBaseType.DOUBLE, - CdsBaseType.DATE, - CdsBaseType.TIME, - CdsBaseType.DATETIME, - CdsBaseType.TIMESTAMP, - CdsBaseType.HANA_SMALLINT, - CdsBaseType.HANA_TINYINT, - CdsBaseType.HANA_SMALLDECIMAL, - CdsBaseType.HANA_REAL, - CdsBaseType.HANA_CHAR, - CdsBaseType.HANA_NCHAR, - CdsBaseType.HANA_VARCHAR, - CdsBaseType.HANA_CLOB); + + private final AICoreService aiCoreService; + private final RecommendationClientResolver clientResolver; + private final RecommendationResultParser resultParser = new RecommendationResultParser(); + private final Cache entitiesWithoutPredictions = + Caffeine.newBuilder().maximumSize(10_000).build(); FioriRecommendationHandler( AICoreService aiCoreService, RecommendationClientResolver clientResolver) { @@ -88,7 +47,7 @@ public void afterRead(CdsReadEventContext context, List dataList) { return; } String entityName = target.getQualifiedName(); - if (entitiesWithoutPredictions.contains(entityName)) { + if (entitiesWithoutPredictions.getIfPresent(entityName) != null) { return; } @@ -110,47 +69,7 @@ public void afterRead(CdsReadEventContext context, List dataList) { if (rowType == null) { rowType = target; } - List predictionElementNames = - rowType - .elements() - .filter( - byAnnotation(VALUE_LIST_ANNOTATION) - .or(byAnnotation(VALUE_LIST_WITH_FIXED_VALUES_ANNOTATION))) - .filter(e -> !e.getType().isAssociation()) - .map(CdsElement::getName) - .toList(); - if (predictionElementNames.isEmpty()) { - entitiesWithoutPredictions.add(entityName); - return; - } - List contextColumns = - rowType - .concreteNonAssociationElements() - .filter(e -> e.getType().isSimple()) - .filter( - e -> - SUPPORTED_CONTEXT_TYPES.contains(e.getType().as(CdsSimpleType.class).getType())) - .filter(e -> !Drafts.ELEMENTS.contains(e.getName())) - .map(CdsElement::getName) - .toList(); - if (contextColumns.isEmpty()) { - logger.debug("No suitable context columns found, skipping predictions."); - return; - } - - List keyNames = target.keyElements().map(CdsElement::getName).toList(); - boolean syntheticKeyNeeded = - keyNames.size() > 1 || (keyNames.size() == 1 && !"ID".equals(keyNames.get(0))); - String indexColumn = - syntheticKeyNeeded ? SYNTHETIC_KEY_COLUMN : keyNames.stream().findFirst().orElse("ID"); - - List selectColumns = new ArrayList<>(contextColumns); - for (String key : keyNames) { - if (!selectColumns.contains(key)) { - selectColumns.add(key); - } - } int limit = context .getCdsRuntime() @@ -159,52 +78,41 @@ public void afterRead(CdsReadEventContext context, List dataList) { "cds.requires.recommendations.contextRowLimit", Integer.class, DEFAULT_CONTEXT_ROW_LIMIT); - var select = - Select.from(target.getQualifiedName()) - .columns(selectColumns.toArray(String[]::new)) - .where( - predictionElementNames.stream() - .map(col -> CQL.get(col).isNotNull()) - .collect(CQL.withAnd())) - .limit(limit); - target - .concreteNonAssociationElements() - .filter(byAnnotation("cds.on.update")) - .map(CdsElement::getName) - .findFirst() - .or(() -> target.keyElements().map(CdsElement::getName).findFirst()) - .ifPresent(col -> select.orderBy(CQL.get(col).desc())); + + var builder = new RecommendationContextBuilder(target, rowType, limit); + + if (builder.predictionElementNames().isEmpty()) { + entitiesWithoutPredictions.put(entityName, Boolean.TRUE); + return; + } + + if (builder.contextColumns().isEmpty()) { + logger.debug("No suitable context columns found, skipping predictions."); + return; + } PersistenceService db = context .getServiceCatalog() .getService(PersistenceService.class, PersistenceService.DEFAULT_NAME); - List contextRows = new ArrayList<>(db.run(select).list()); + 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 = buildPredictRow(row, predictionElementNames); + CdsData predictRow = builder.buildPredictRow(row); if (predictRow == null) { + logger.debug("Current row already has values for all prediction columns, skipping."); return; } - List allRows = new ArrayList<>(); - if (syntheticKeyNeeded) { - for (CdsData contextRow : contextRows) { - contextRow.put(SYNTHETIC_KEY_COLUMN, computeSyntheticKey(contextRow, keyNames)); - allRows.add(contextRow); - } - predictRow.put(SYNTHETIC_KEY_COLUMN, computeSyntheticKey(row, keyNames)); - } else { - allRows.addAll(contextRows); - } - allRows.add(predictRow); + List allRows = builder.assembleRows(contextRows, predictRow, row); String tenantId = context.getUserInfo().getTenant(); RecommendationClient client = clientResolver.resolve(aiCoreService, tenantId); - List predictions = client.predict(allRows, predictionElementNames, indexColumn); + List predictions = + client.predict(allRows, builder.predictionElementNames(), builder.indexColumn()); if (predictions.isEmpty()) { logger.warn("No predictions returned from AI client."); @@ -216,188 +124,10 @@ public void afterRead(CdsReadEventContext context, List dataList) { } List missingPredictionElementNames = - predictionElementNames.stream().filter(c -> row.get(c) == null).toList(); + builder.predictionElementNames().stream().filter(c -> row.get(c) == null).toList(); Map recommendations = - buildRecommendations(db, predictions.get(0), missingPredictionElementNames, context, rowType); + resultParser.buildRecommendations( + db, predictions.get(0), missingPredictionElementNames, context, rowType); row.put("SAP_Recommendations", recommendations); } - - private CdsData buildPredictRow(CdsData row, List predictionElementNames) { - if (predictionElementNames.stream().noneMatch(c -> row.get(c) == null)) { - logger.debug("Current row already has values for all prediction columns, skipping."); - return null; - } - Map predictRow = new HashMap<>(row); - Drafts.ELEMENTS.forEach(predictRow::remove); - for (String col : predictionElementNames) { - predictRow.putIfAbsent(col, "[PREDICT]"); - } - return CdsData.create(predictRow); - } - - private 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)); - sb.append('\0'); - Object value = row.get(keyNames.get(i)); - if (value != null) { - sb.append(value); - } - } - return sb.toString(); - } - - private Map buildRecommendations( - PersistenceService db, - CdsData prediction, - List predictionElementNames, - CdsReadEventContext context, - CdsStructuredType rowType) { - Map textPaths = resolveTextPaths(predictionElementNames, context); - - Map parsedValues = new HashMap<>(); - for (String col : predictionElementNames) { - Object obj = prediction.get(col); - if (!(obj instanceof List list) - || list.isEmpty() - || !(list.get(0) instanceof Map map)) { - continue; - } - CdsBaseType baseType = - rowType - .findElement(col) - .filter(e -> e.getType().isSimple()) - .map(e -> e.getType().as(CdsSimpleType.class).getType()) - .orElse(CdsBaseType.STRING); - parsedValues.put(col, parseValue(map.get("prediction"), baseType)); - } - - Map descriptions = - resolveDescriptionsBatch(db, parsedValues, textPaths, context); - - Map recommendations = new HashMap<>(); - for (Map.Entry entry : parsedValues.entrySet()) { - String col = entry.getKey(); - Object recommendedValue = entry.getValue(); - Map values = new HashMap<>(); - values.put("RecommendedFieldValue", recommendedValue); - values.put("RecommendedFieldDescription", descriptions.getOrDefault(col, "")); - values.put("RecommendedFieldScoreValue", 0.5); - values.put("RecommendedFieldIsSuggestion", true); - recommendations.put(col, List.of(values)); - } - return recommendations; - } - - private Map resolveTextPaths( - List predictionElementNames, CdsReadEventContext context) { - CdsStructuredType target = context.getTarget(); - Map fkToAssociation = buildFkToAssociationMap(target); - Map textPaths = new HashMap<>(); - for (String col : predictionElementNames) { - Optional path; - String assocName = fkToAssociation.get(col); - if (assocName != null) { - path = getTextPath(context, assocName); - if (path.isEmpty()) { - path = getTextPath(context, col); - } - } else { - path = getTextPath(context, col); - } - path.ifPresent(p -> textPaths.put(col, p)); - } - return textPaths; - } - - private Map buildFkToAssociationMap(CdsStructuredType target) { - Map map = new HashMap<>(); - target - .associations() - .forEach( - assocElement -> { - CdsAssociationType assocType = assocElement.getType().as(CdsAssociationType.class); - String assocName = assocElement.getName(); - assocType - .refs() - .forEach(ref -> map.put(assocName + "_" + ref.lastSegment(), assocName)); - }); - return map; - } - - private Object parseValue(Object value, CdsBaseType baseType) { - if (value == null) { - return null; - } - String s = value.toString(); - try { - return switch (baseType) { - case INTEGER, INT16, INT32, UINT8, HANA_SMALLINT, HANA_TINYINT -> Integer.valueOf(s); - case INT64, INTEGER64 -> Long.valueOf(s); - case DECIMAL, DECIMAL_FLOAT, HANA_SMALLDECIMAL -> new BigDecimal(s); - case DOUBLE, HANA_REAL -> Double.valueOf(s); - case BOOLEAN -> Boolean.valueOf(s); - default -> s; - }; - } catch (NumberFormatException e) { - return s; - } - } - - private Map resolveDescriptionsBatch( - PersistenceService db, - Map parsedValues, - Map textPaths, - CdsReadEventContext context) { - Map descriptions = new HashMap<>(); - if (textPaths.isEmpty()) { - return descriptions; - } - String entity = context.getTarget().getQualifiedName(); - for (Map.Entry entry : parsedValues.entrySet()) { - String col = entry.getKey(); - String path = textPaths.get(col); - if (path == null) { - continue; - } - String[] parts = path.split("\\."); - if (parts.length != 2) { - logger.debug( - "Text path {} for column {} is not in expected format 'association.textField'.", - path, - col); - continue; - } - Result r = - db.run( - Select.from(entity) - .columns(b -> b.to(parts[0]).get(parts[1]).as("desc")) - .where(CQL.get(col).eq(entry.getValue())) - .limit(1)); - r.first() - .map(row -> row.get("desc")) - .filter(Objects::nonNull) - .ifPresent(d -> descriptions.put(col, d.toString())); - } - return descriptions; - } - - private Optional getTextPath(CdsReadEventContext context, String columnName) { - return context - .getTarget() - .findElement(columnName) - .flatMap(e -> e.findAnnotation("@Common.Text")) - .flatMap( - a -> { - Object val = a.getValue(); - if (val instanceof String s) return Optional.of(s); - if (val instanceof Map m && m.get("=") != null) - return Optional.of(m.get("=").toString()); - return Optional.empty(); - }); - } } 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 new file mode 100644 index 0000000..021a6a3 --- /dev/null +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationContextBuilder.java @@ -0,0 +1,188 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. + */ +package com.sap.cds.feature.recommendation; + +import static com.sap.cds.reflect.CdsAnnotatable.byAnnotation; + +import com.sap.cds.CdsData; +import com.sap.cds.ql.CQL; +import com.sap.cds.ql.Select; +import com.sap.cds.ql.cqn.CqnSelect; +import com.sap.cds.reflect.CdsBaseType; +import com.sap.cds.reflect.CdsElement; +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.List; +import java.util.Map; +import java.util.Set; + +/** + * 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. + */ +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, + CdsBaseType.LARGE_STRING, + CdsBaseType.UUID, + CdsBaseType.BOOLEAN, + CdsBaseType.INTEGER, + CdsBaseType.UINT8, + CdsBaseType.INT16, + CdsBaseType.INT32, + CdsBaseType.INT64, + CdsBaseType.INTEGER64, + CdsBaseType.DECIMAL, + CdsBaseType.DOUBLE, + CdsBaseType.DATE, + CdsBaseType.TIME, + CdsBaseType.DATETIME, + CdsBaseType.TIMESTAMP, + CdsBaseType.HANA_SMALLINT, + CdsBaseType.HANA_TINYINT, + CdsBaseType.HANA_SMALLDECIMAL, + CdsBaseType.HANA_REAL, + CdsBaseType.HANA_CHAR, + CdsBaseType.HANA_NCHAR, + CdsBaseType.HANA_VARCHAR, + CdsBaseType.HANA_CLOB); + + private final CdsStructuredType target; + private final CdsStructuredType rowType; + private final int contextRowLimit; + 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; + this.rowType = rowType; + this.contextRowLimit = limit; + 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() { + return predictionElementNames; + } + + List contextColumns() { + return contextColumns; + } + + String indexColumn() { + return indexColumn; + } + + boolean syntheticKeyNeeded() { + return syntheticKeyNeeded; + } + + CqnSelect buildContextQuery() { + List selectColumns = new ArrayList<>(contextColumns); + for (String key : keyNames) { + if (!selectColumns.contains(key)) { + selectColumns.add(key); + } + } + var select = + Select.from(target.getQualifiedName()) + .columns(selectColumns.toArray(String[]::new)) + .where( + predictionElementNames.stream() + .map(col -> CQL.get(col).isNotNull()) + .collect(CQL.withAnd())) + .limit(contextRowLimit); + target + .concreteNonAssociationElements() + .filter(byAnnotation("cds.on.update")) + .map(CdsElement::getName) + .findFirst() + .or(() -> target.keyElements().map(CdsElement::getName).findFirst()) + .ifPresent(col -> select.orderBy(CQL.get(col).desc())); + return select; + } + + 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); + for (String col : predictionElementNames) { + predictRow.putIfAbsent(col, "[PREDICT]"); + } + return CdsData.create(predictRow); + } + + 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(); + } + + List assembleRows(List contextRows, CdsData predictRow, CdsData currentRow) { + List allRows = new ArrayList<>(); + 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); + } + allRows.add(predictRow); + return allRows; + } + + private List computePredictionElements() { + return rowType + .elements() + .filter( + byAnnotation(VALUE_LIST_ANNOTATION) + .or(byAnnotation(VALUE_LIST_WITH_FIXED_VALUES_ANNOTATION))) + .filter(e -> !e.getType().isAssociation()) + .map(CdsElement::getName) + .toList(); + } + + private List computeContextColumns() { + return rowType + .concreteNonAssociationElements() + .filter(e -> e.getType().isSimple()) + .filter( + e -> SUPPORTED_CONTEXT_TYPES.contains(e.getType().as(CdsSimpleType.class).getType())) + .filter(e -> !Drafts.ELEMENTS.contains(e.getName())) + .map(CdsElement::getName) + .toList(); + } +} diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationResultParser.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationResultParser.java new file mode 100644 index 0000000..5685ed0 --- /dev/null +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationResultParser.java @@ -0,0 +1,182 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. + */ +package com.sap.cds.feature.recommendation; + +import com.sap.cds.CdsData; +import com.sap.cds.Result; +import com.sap.cds.ql.CQL; +import com.sap.cds.ql.Select; +import com.sap.cds.reflect.CdsAssociationType; +import com.sap.cds.reflect.CdsBaseType; +import com.sap.cds.reflect.CdsSimpleType; +import com.sap.cds.reflect.CdsStructuredType; +import com.sap.cds.services.cds.CdsReadEventContext; +import com.sap.cds.services.persistence.PersistenceService; +import java.math.BigDecimal; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Parses AI prediction responses and assembles them into the SAP_Recommendations structure expected + * by Fiori UIs. Handles type coercion, text path resolution, and description lookups. + */ +class RecommendationResultParser { + + private static final Logger logger = LoggerFactory.getLogger(RecommendationResultParser.class); + + Map buildRecommendations( + PersistenceService db, + CdsData prediction, + List predictionElementNames, + CdsReadEventContext context, + CdsStructuredType rowType) { + Map textPaths = resolveTextPaths(predictionElementNames, context); + + Map parsedValues = new HashMap<>(); + for (String col : predictionElementNames) { + Object obj = prediction.get(col); + if (!(obj instanceof List list) + || list.isEmpty() + || !(list.get(0) instanceof Map map)) { + continue; + } + CdsBaseType baseType = + rowType + .findElement(col) + .filter(e -> e.getType().isSimple()) + .map(e -> e.getType().as(CdsSimpleType.class).getType()) + .orElse(CdsBaseType.STRING); + parsedValues.put(col, parseValue(map.get("prediction"), baseType)); + } + + Map descriptions = + resolveDescriptionsBatch(db, parsedValues, textPaths, context); + + Map recommendations = new HashMap<>(); + for (Map.Entry entry : parsedValues.entrySet()) { + String col = entry.getKey(); + Object recommendedValue = entry.getValue(); + Map values = new HashMap<>(); + values.put("RecommendedFieldValue", recommendedValue); + values.put("RecommendedFieldDescription", descriptions.getOrDefault(col, "")); + values.put("RecommendedFieldScoreValue", 0.5); + values.put("RecommendedFieldIsSuggestion", true); + recommendations.put(col, List.of(values)); + } + return recommendations; + } + + private Object parseValue(Object value, CdsBaseType baseType) { + if (value == null) { + return null; + } + String s = value.toString(); + try { + return switch (baseType) { + case INTEGER, INT16, INT32, UINT8, HANA_SMALLINT, HANA_TINYINT -> Integer.valueOf(s); + case INT64, INTEGER64 -> Long.valueOf(s); + case DECIMAL, DECIMAL_FLOAT, HANA_SMALLDECIMAL -> new BigDecimal(s); + case DOUBLE, HANA_REAL -> Double.valueOf(s); + case BOOLEAN -> Boolean.valueOf(s); + default -> s; + }; + } catch (NumberFormatException e) { + return s; + } + } + + private Map resolveTextPaths( + List predictionElementNames, CdsReadEventContext context) { + CdsStructuredType target = context.getTarget(); + Map fkToAssociation = buildFkToAssociationMap(target); + Map textPaths = new HashMap<>(); + for (String col : predictionElementNames) { + Optional path; + String assocName = fkToAssociation.get(col); + if (assocName != null) { + path = getTextPath(context, assocName); + if (path.isEmpty()) { + path = getTextPath(context, col); + } + } else { + path = getTextPath(context, col); + } + path.ifPresent(p -> textPaths.put(col, p)); + } + return textPaths; + } + + private Map buildFkToAssociationMap(CdsStructuredType target) { + Map map = new HashMap<>(); + target + .associations() + .forEach( + assocElement -> { + CdsAssociationType assocType = assocElement.getType().as(CdsAssociationType.class); + String assocName = assocElement.getName(); + assocType + .refs() + .forEach(ref -> map.put(assocName + "_" + ref.lastSegment(), assocName)); + }); + return map; + } + + private Map resolveDescriptionsBatch( + PersistenceService db, + Map parsedValues, + Map textPaths, + CdsReadEventContext context) { + Map descriptions = new HashMap<>(); + if (textPaths.isEmpty()) { + return descriptions; + } + String entity = context.getTarget().getQualifiedName(); + for (Map.Entry entry : parsedValues.entrySet()) { + String col = entry.getKey(); + String path = textPaths.get(col); + if (path == null) { + continue; + } + String[] parts = path.split("\\."); + if (parts.length != 2) { + logger.debug( + "Text path {} for column {} is not in expected format 'association.textField'.", + path, + col); + continue; + } + Result r = + db.run( + Select.from(entity) + .columns(b -> b.to(parts[0]).get(parts[1]).as("desc")) + .where(CQL.get(col).eq(entry.getValue())) + .limit(1)); + r.first() + .map(row -> row.get("desc")) + .filter(Objects::nonNull) + .ifPresent(d -> descriptions.put(col, d.toString())); + } + return descriptions; + } + + private Optional getTextPath(CdsReadEventContext context, String columnName) { + return context + .getTarget() + .findElement(columnName) + .flatMap(e -> e.findAnnotation("@Common.Text")) + .flatMap( + a -> { + Object val = a.getValue(); + if (val instanceof String s) return Optional.of(s); + if (val instanceof Map m && m.get("=") != null) + return Optional.of(m.get("=").toString()); + return Optional.empty(); + }); + } +} From 5c71d8594e3e2cce46b16b044dcdcddcaa7ff8d3 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Wed, 27 May 2026 12:24:44 +0200 Subject: [PATCH 5/6] refactor: accept SDK API clients as constructor parameters Add overloaded constructor to AICoreServiceImpl that accepts DeploymentApi, ConfigurationApi, ResourceGroupApi, and AiCoreService as parameters. The existing convenience constructor delegates to it with default instances. This enables direct injection of mocked clients in unit tests without needing to mock getter methods on the service itself. Closes #36 --- .../aicore/core/AICoreServiceImpl.java | 26 ++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) 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 index f0897cf..78e7321 100644 --- 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 @@ -67,6 +67,24 @@ public class AICoreServiceImpl extends AbstractCqnService implements AICoreServi private final AiCoreService sdkService; public AICoreServiceImpl(String name, CdsRuntime runtime, boolean multiTenancyEnabled) { + this( + name, + runtime, + multiTenancyEnabled, + new DeploymentApi(), + new ConfigurationApi(), + new ResourceGroupApi(), + new AiCoreService()); + } + + public AICoreServiceImpl( + String name, + CdsRuntime runtime, + boolean multiTenancyEnabled, + DeploymentApi deploymentApi, + ConfigurationApi configurationApi, + ResourceGroupApi resourceGroupApi, + AiCoreService sdkService) { super(name, runtime); this.multiTenancyEnabled = multiTenancyEnabled; CdsEnvironment env = runtime.getEnvironment(); @@ -83,10 +101,10 @@ public AICoreServiceImpl(String name, CdsRuntime runtime, boolean multiTenancyEn this.tenantResourceGroupCache = newCache(); this.resourceGroupDeploymentCache = newCache(); this.deploymentLocks = newCache(); - this.deploymentApi = new DeploymentApi(); - this.configurationApi = new ConfigurationApi(); - this.resourceGroupApi = new ResourceGroupApi(); - this.sdkService = new AiCoreService(); + this.deploymentApi = deploymentApi; + this.configurationApi = configurationApi; + this.resourceGroupApi = resourceGroupApi; + this.sdkService = sdkService; } private static Cache newCache() { From 1f2a4241ac64f52e12b787d1bdcec5f0924ef8fd Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Thu, 28 May 2026 10:24:26 +0200 Subject: [PATCH 6/6] refactor: remove convenience constructor, pass API clients explicitly Remove the delegating convenience constructor from AICoreServiceImpl. The only caller (AICoreServiceConfiguration) now passes DeploymentApi, ConfigurationApi, ResourceGroupApi, and AiCoreService explicitly, making the dependency wiring fully transparent at the creation site. Closes #36 --- .../aicore/core/AICoreServiceConfiguration.java | 13 ++++++++++++- .../cds/feature/aicore/core/AICoreServiceImpl.java | 11 ----------- 2 files changed, 12 insertions(+), 12 deletions(-) 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 2d6c372..eca6c25 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 @@ -3,6 +3,10 @@ */ package com.sap.cds.feature.aicore.core; +import com.sap.ai.sdk.core.AiCoreService; +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.core.handler.AICoreApplicationServiceHandler; import com.sap.cds.feature.aicore.core.handler.ActionHandler; import com.sap.cds.feature.aicore.core.handler.ConfigurationHandler; @@ -48,7 +52,14 @@ public void services(CdsRuntimeConfigurer configurer) { if (hasBinding) { AICoreServiceImpl service = - new AICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime, multiTenancyEnabled); + new AICoreServiceImpl( + AICoreService.DEFAULT_NAME, + runtime, + multiTenancyEnabled, + new DeploymentApi(), + new ConfigurationApi(), + new ResourceGroupApi(), + new AiCoreService()); configurer.service(service); logger.info("Registered AICoreService backed by AI Core binding."); } else { 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 index 0a7a516..207de4b 100644 --- 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 @@ -65,17 +65,6 @@ public class AICoreServiceImpl extends AbstractAICoreService { private final ResourceGroupApi resourceGroupApi; private final AiCoreService sdkService; - public AICoreServiceImpl(String name, CdsRuntime runtime, boolean multiTenancyEnabled) { - this( - name, - runtime, - multiTenancyEnabled, - new DeploymentApi(), - new ConfigurationApi(), - new ResourceGroupApi(), - new AiCoreService()); - } - public AICoreServiceImpl( String name, CdsRuntime runtime,