Skip to content

Commit 4464f04

Browse files
author
Michal Zelencik
committed
Merge branch 'improvement/categorical-mappings-endpoint-2'
2 parents d547e64 + 2e53742 commit 4464f04

File tree

20 files changed

+1100
-153
lines changed

20 files changed

+1100
-153
lines changed

infra/schema/src/main/resources/xml/ns/public/common/common-smart-integration-3.xsd

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1481,6 +1481,57 @@
14811481
</xsd:sequence>
14821482
</xsd:complexType>
14831483

1484+
<xsd:complexType name="SiSuggestCategoricalMappingRequestType">
1485+
<xsd:annotation>
1486+
<xsd:documentation>
1487+
Request for "suggestCategoricalMapping" method.
1488+
Used when no correlated data is available but the source attribute appears to be categorical
1489+
(small number of distinct values matching an MP enum attribute like activation/administrativeStatus).
1490+
</xsd:documentation>
1491+
<xsd:appinfo>
1492+
<a:since>4.11</a:since>
1493+
<!-- intentionally not a container -->
1494+
</xsd:appinfo>
1495+
</xsd:annotation>
1496+
<xsd:sequence>
1497+
<xsd:element name="applicationAttribute" type="tns:SiAttributeDefinitionType">
1498+
<xsd:annotation>
1499+
<xsd:documentation>
1500+
Definition of the application-side attribute.
1501+
</xsd:documentation>
1502+
</xsd:annotation>
1503+
</xsd:element>
1504+
<xsd:element name="midPointAttribute" type="tns:SiAttributeDefinitionType">
1505+
<xsd:annotation>
1506+
<xsd:documentation>
1507+
Definition of the midPoint-side categorical attribute.
1508+
</xsd:documentation>
1509+
</xsd:annotation>
1510+
</xsd:element>
1511+
<xsd:element name="inbound" type="xsd:boolean">
1512+
<xsd:annotation>
1513+
<xsd:documentation>
1514+
Is the mapping to be produced an inbound one?
1515+
</xsd:documentation>
1516+
</xsd:annotation>
1517+
</xsd:element>
1518+
<xsd:element name="applicationAttributeValue" type="xsd:string" minOccurs="0" maxOccurs="unbounded">
1519+
<xsd:annotation>
1520+
<xsd:documentation>
1521+
Distinct values of the application-side attribute from statistics.
1522+
</xsd:documentation>
1523+
</xsd:annotation>
1524+
</xsd:element>
1525+
<xsd:element name="midPointCategoryValue" type="xsd:string" minOccurs="0" maxOccurs="unbounded">
1526+
<xsd:annotation>
1527+
<xsd:documentation>
1528+
Known enum values of the midPoint categorical attribute (e.g. "enabled", "disabled", "archived").
1529+
</xsd:documentation>
1530+
</xsd:annotation>
1531+
</xsd:element>
1532+
</xsd:sequence>
1533+
</xsd:complexType>
1534+
14841535
<xsd:complexType name="SiSuggestMappingResponseType">
14851536
<xsd:annotation>
14861537
<xsd:documentation>

infra/schema/src/main/resources/xml/ns/public/common/common-tasks-3.xsd

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11234,6 +11234,18 @@
1123411234
<xsd:complexContent>
1123511235
<xsd:extension base="tns:AbstractActivityWorkStateType">
1123611236
<xsd:sequence>
11237+
<xsd:element name="statisticsRef" type="tns:ObjectReferenceType" minOccurs="0">
11238+
<xsd:annotation>
11239+
<xsd:documentation>
11240+
Reference to the object type statistics object computed before the mapping suggestion.
11241+
Used for categorical mapping suggestions when no correlated data is available.
11242+
</xsd:documentation>
11243+
<xsd:appinfo>
11244+
<a:displayName>MappingsSuggestionWorkStateType.statisticsRef</a:displayName>
11245+
<a:objectReferenceTargetType>c:GenericObjectType</a:objectReferenceTargetType>
11246+
</xsd:appinfo>
11247+
</xsd:annotation>
11248+
</xsd:element>
1123711249
<xsd:element name="schemaMatchRef" type="tns:ObjectReferenceType" minOccurs="0">
1123811250
<xsd:annotation>
1123911251
<xsd:documentation>

model/model-intest/src/test/java/com/evolveum/midpoint/model/intest/smart/TestSmartIntegrationService.java

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
import java.util.List;
2121
import javax.xml.namespace.QName;
2222

23+
import com.evolveum.midpoint.prism.path.ItemPath;
24+
2325
import org.springframework.beans.factory.annotation.Autowired;
2426
import org.springframework.test.annotation.DirtiesContext;
2527
import org.springframework.test.context.ContextConfiguration;
@@ -61,6 +63,7 @@ public class TestSmartIntegrationService extends AbstractEmptyModelIntegrationTe
6163
@Autowired private SmartIntegrationService smartIntegrationService;
6264

6365
private static DummyBasicScenario dummyForSuggestCorrelationAndMappings;
66+
private static DummyBasicScenario dummyForSuggestCategoricalMappings;
6467

6568
private static final DummyTestResource RESOURCE_DUMMY_FOR_SUGGEST_OBJECT_TYPES = new DummyTestResource(
6669
TEST_DIR, "resource-dummy-for-suggest-object-types.xml", "0c59d761-bea9-4342-bbc7-ee0e199d275b",
@@ -78,6 +81,12 @@ public class TestSmartIntegrationService extends AbstractEmptyModelIntegrationTe
7881
resourceController -> dummyForSuggestCorrelationAndMappings = DummyBasicScenario.on(resourceController)
7982
.initialize());
8083

84+
private static final DummyTestResource RESOURCE_DUMMY_FOR_SUGGEST_CATEGORICAL_MAPPINGS = new DummyTestResource(
85+
TEST_DIR, "resource-dummy-for-suggest-categorical-mappings.xml", "a1b2c3d4-e5f6-7890-abcd-ef1234560001",
86+
"for-suggest-categorical-mappings",
87+
resourceController -> dummyForSuggestCategoricalMappings = DummyBasicScenario.on(resourceController)
88+
.initialize());
89+
8190
private static final TestObject<?> USER_JACK = TestObject.file(TEST_DIR, "user-jack.xml", "84d2ff68-9b32-4ef4-b87b-02536fd5e83c");
8291
private static final TestObject<?> USER_JIM = TestObject.file(TEST_DIR, "user-jim.xml", "8f433649-6cc4-401b-910f-10fa5449f14c");
8392
private static final TestObject<?> USER_ALICE = TestObject.file(TEST_DIR, "user-alice.xml", "79df4c1f-6480-4eb8-9db7-863e25d5b5fa");
@@ -93,10 +102,24 @@ public void initSystem(Task initTask, OperationResult initResult) throws Excepti
93102
initAndTestDummyResource(RESOURCE_DUMMY_FOR_SUGGEST_OBJECT_TYPES, initTask, initResult);
94103
initAndTestDummyResource(RESOURCE_DUMMY_FOR_SUGGEST_FOCUS_TYPE, initTask, initResult);
95104
initAndTestDummyResource(RESOURCE_DUMMY_FOR_SUGGEST_CORRELATION_AND_MAPPINGS, initTask, initResult);
105+
initAndTestDummyResource(RESOURCE_DUMMY_FOR_SUGGEST_CATEGORICAL_MAPPINGS, initTask, initResult);
96106

97107
initTestObjects(initTask, initResult,
98108
USER_JACK, USER_JIM, USER_ALICE, USER_BOB);
99109
createAndLinkAccounts(initTask, initResult);
110+
createUnlinkedCategoricalAccounts();
111+
}
112+
113+
private void createUnlinkedCategoricalAccounts() throws Exception {
114+
var a = dummyForSuggestCategoricalMappings.account;
115+
a.add("worker1")
116+
.addAttributeValues(DummyBasicScenario.Account.AttributeNames.STATUS.local(), "active");
117+
a.add("worker2")
118+
.addAttributeValues(DummyBasicScenario.Account.AttributeNames.STATUS.local(), "active");
119+
a.add("worker3")
120+
.addAttributeValues(DummyBasicScenario.Account.AttributeNames.STATUS.local(), "inactive");
121+
a.add("worker4")
122+
.addAttributeValues(DummyBasicScenario.Account.AttributeNames.STATUS.local(), "inactive");
100123
}
101124

102125
private void createAndLinkAccounts(Task initTask, OperationResult initResult) throws Exception {
@@ -422,6 +445,150 @@ public void test310SuggestMappingsWithFailure() throws CommonException {
422445
.display();
423446
}
424447

448+
/**
449+
* Tests categorical mapping suggestion for the lockout status attribute.
450+
* Shadow 'status' has 2 unique values ("active", "inactive"); LockoutStatusType has 2 enum values → cardinality check passes.
451+
*/
452+
@Test
453+
public void test400SuggestMappingsCategorical() throws CommonException {
454+
if (DefaultServiceClientImpl.hasServiceUrlOverride()) {
455+
// We'll go with the real service client. Hence, this test will not check the actual response in detail.
456+
} else {
457+
var mockClient = new MockServiceClientImpl(request -> {
458+
if (request instanceof SiMatchSchemaRequestType) {
459+
return new SiMatchSchemaResponseType()
460+
.attributeMatch(new SiAttributeMatchSuggestionType()
461+
.applicationAttribute(asStringSimple(
462+
DummyBasicScenario.Account.AttributeNames.STATUS.path()))
463+
.midPointAttribute(asStringSimple(
464+
ItemPath.create(UserType.F_ACTIVATION, ActivationType.F_LOCKOUT_STATUS))));
465+
} else if (request instanceof SiSuggestCategoricalMappingRequestType) {
466+
return new SiSuggestMappingResponseType()
467+
.transformationScript(
468+
"// Map status to lockoutStatus\n"
469+
+ "input == null ? null"
470+
+ " : input.equalsIgnoreCase(\"inactive\") ? \"locked\" : \"normal\"");
471+
}
472+
return null;
473+
});
474+
TestServiceClientFactory.mockServiceClient(this.clientFactoryMock, mockClient);
475+
}
476+
477+
var task = getTestTask();
478+
var result = task.getResult();
479+
480+
when("submitting 'suggest mappings' operation for categorical lockout status attribute");
481+
var token = smartIntegrationService.submitSuggestMappingsOperation(
482+
RESOURCE_DUMMY_FOR_SUGGEST_CATEGORICAL_MAPPINGS.oid, ACCOUNT_DEFAULT, true, null,
483+
List.of(DataAccessPermissionType.SCHEMA_ACCESS,
484+
DataAccessPermissionType.STATISTICS_ACCESS,
485+
DataAccessPermissionType.RAW_DATA_ACCESS),
486+
task, result);
487+
488+
then("returned token is not null");
489+
assertThat(token).isNotNull();
490+
491+
when("waiting for the operation to finish successfully");
492+
var response = waitForFinish(
493+
() -> smartIntegrationService.getSuggestMappingsOperationStatus(token, task, result),
494+
TIMEOUT);
495+
496+
then("there are suggested mappings");
497+
displayDumpable("response", response);
498+
assertThat(response).isNotNull();
499+
var attrMappings = response.getAttributeMappings();
500+
assertThat(attrMappings).as("attribute mappings").isNotEmpty();
501+
502+
if (!DefaultServiceClientImpl.hasServiceUrlOverride()) {
503+
and("the status attribute mapping has a categorical transformation script");
504+
var statusMapping = findStatusMapping(attrMappings);
505+
assertThat(statusMapping).as("status attribute mapping").isNotNull();
506+
var inbound = statusMapping.getDefinition().getInbound();
507+
assertThat(inbound).as("inbound mappings for status").hasSize(1);
508+
assertThat(inbound.get(0).getExpression())
509+
.as("transformation expression (categorical mapping script)")
510+
.isNotNull();
511+
512+
and("the categorical mapping has null expected quality");
513+
assertThat(statusMapping.getExpectedQuality())
514+
.as("expected quality of categorical mapping should be null (LLM confidence not assessed)")
515+
.isNull();
516+
}
517+
}
518+
519+
/**
520+
* Tests that categorical mapping is NOT triggered when RAW_DATA_ACCESS is absent.
521+
* Without it, useAiService=false, so the categorical path is skipped and the mapping falls back to asIs (null expression).
522+
*/
523+
@Test
524+
public void test410SuggestMappingsCategoricalWithoutRawDataAccess() throws CommonException {
525+
if (DefaultServiceClientImpl.hasServiceUrlOverride()) {
526+
// We'll go with the real service client. Hence, this test will not check the actual response in detail.
527+
} else {
528+
var mockClient = new MockServiceClientImpl(request -> {
529+
if (request instanceof SiMatchSchemaRequestType) {
530+
return new SiMatchSchemaResponseType()
531+
.attributeMatch(new SiAttributeMatchSuggestionType()
532+
.applicationAttribute(asStringSimple(
533+
DummyBasicScenario.Account.AttributeNames.STATUS.path()))
534+
.midPointAttribute(asStringSimple(
535+
ItemPath.create(UserType.F_ACTIVATION, ActivationType.F_LOCKOUT_STATUS))));
536+
} else if (request instanceof SiSuggestCategoricalMappingRequestType) {
537+
throw new AssertionError(
538+
"SUGGEST_CATEGORICAL_MAPPING must not be called without RAW_DATA_ACCESS (useAiService=false)");
539+
}
540+
return null;
541+
});
542+
TestServiceClientFactory.mockServiceClient(this.clientFactoryMock, mockClient);
543+
}
544+
545+
var task = getTestTask();
546+
var result = task.getResult();
547+
548+
when("submitting 'suggest mappings' operation without RAW_DATA_ACCESS");
549+
var token = smartIntegrationService.submitSuggestMappingsOperation(
550+
RESOURCE_DUMMY_FOR_SUGGEST_CATEGORICAL_MAPPINGS.oid, ACCOUNT_DEFAULT, true, null,
551+
List.of(DataAccessPermissionType.SCHEMA_ACCESS,
552+
DataAccessPermissionType.STATISTICS_ACCESS),
553+
task, result);
554+
555+
then("returned token is not null");
556+
assertThat(token).isNotNull();
557+
558+
when("waiting for the operation to finish");
559+
var response = waitForFinish(
560+
() -> smartIntegrationService.getSuggestMappingsOperationStatus(token, task, result),
561+
TIMEOUT);
562+
563+
then("there are suggested mappings");
564+
displayDumpable("response", response);
565+
assertThat(response).isNotNull();
566+
var attrMappings = response.getAttributeMappings();
567+
assertThat(attrMappings).as("attribute mappings").isNotEmpty();
568+
569+
if (!DefaultServiceClientImpl.hasServiceUrlOverride()) {
570+
and("the status mapping has null expression — asIs, because useAiService was false");
571+
var statusMapping = findStatusMapping(attrMappings);
572+
assertThat(statusMapping).as("status attribute mapping").isNotNull();
573+
var inbound = statusMapping.getDefinition().getInbound();
574+
assertThat(inbound).as("inbound mappings for status").hasSize(1);
575+
assertThat(inbound.get(0).getExpression())
576+
.as("expression should be null (asIs) — categorical mapping disabled without RAW_DATA_ACCESS")
577+
.isNull();
578+
}
579+
}
580+
581+
private AttributeMappingsSuggestionType findStatusMapping(List<AttributeMappingsSuggestionType> attrMappings) {
582+
return attrMappings.stream()
583+
.filter(m -> {
584+
var ref = m.getDefinition().getRef();
585+
return ref != null && ref.getItemPath().lastName().getLocalPart().equals(
586+
DummyBasicScenario.Account.AttributeNames.STATUS.local());
587+
})
588+
.findFirst()
589+
.orElse(null);
590+
}
591+
425592
private void skipIfRealService() {
426593
skipTestIf(DefaultServiceClientImpl.hasServiceUrlOverride(), "Not applicable with a real service");
427594
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!--
3+
~ Copyright (C) 2010-2026 Evolveum and contributors
4+
~
5+
~ This work is dual-licensed under the Apache License 2.0
6+
~ and European Union Public License. See LICENSE file for details.
7+
-->
8+
9+
<resource oid="a1b2c3d4-e5f6-7890-abcd-ef1234560001"
10+
xmlns="http://midpoint.evolveum.com/xml/ns/public/common/common-3"
11+
xmlns:q="http://prism.evolveum.com/xml/ns/public/query-3"
12+
xmlns:ri="http://midpoint.evolveum.com/xml/ns/public/resource/instance-3">
13+
<name>resource-dummy-for-suggest-categorical-mappings</name>
14+
<connectorRef type="ConnectorType">
15+
<filter>
16+
<q:and>
17+
<q:equal>
18+
<q:path>connectorType</q:path>
19+
<q:value>com.evolveum.icf.dummy.connector.DummyConnector</q:value>
20+
</q:equal>
21+
<q:equal>
22+
<q:path>connectorVersion</q:path>
23+
<q:value>2.0</q:value>
24+
</q:equal>
25+
</q:and>
26+
</filter>
27+
</connectorRef>
28+
<connectorConfiguration xmlns:icfi="http://midpoint.evolveum.com/xml/ns/public/connector/icf-1/bundle/com.evolveum.icf.dummy/com.evolveum.icf.dummy.connector.DummyConnector"
29+
xmlns:icfc="http://midpoint.evolveum.com/xml/ns/public/connector/icf-1/connector-schema-3">
30+
<icfc:configurationProperties>
31+
<icfi:instanceId>for-suggest-categorical-mappings</icfi:instanceId>
32+
<icfi:useLegacySchema>false</icfi:useLegacySchema>
33+
</icfc:configurationProperties>
34+
</connectorConfiguration>
35+
<schemaHandling>
36+
<objectType>
37+
<kind>account</kind>
38+
<intent>default</intent>
39+
<default>true</default>
40+
<delineation>
41+
<objectClass>ri:account</objectClass>
42+
</delineation>
43+
<focus>
44+
<type>UserType</type>
45+
</focus>
46+
</objectType>
47+
</schemaHandling>
48+
</resource>

model/smart-api/src/main/java/com/evolveum/midpoint/smart/api/ServiceClient.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,6 @@ public interface ServiceClient extends AutoCloseable {
3030
void close();
3131

3232
enum Method {
33-
SUGGEST_OBJECT_TYPES, SUGGEST_FOCUS_TYPE, MATCH_SCHEMA, SUGGEST_MAPPING;
33+
SUGGEST_OBJECT_TYPES, SUGGEST_FOCUS_TYPE, MATCH_SCHEMA, SUGGEST_MAPPING, SUGGEST_CATEGORICAL_MAPPING;
3434
}
3535
}

model/smart-api/src/main/java/com/evolveum/midpoint/smart/api/SmartIntegrationService.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,7 @@ MappingsSuggestionType suggestMappings(
261261
SchemaMatchResultType schemaMatch,
262262
Boolean isInbound,
263263
Boolean useAiService,
264+
@Nullable ShadowObjectClassStatisticsType objectTypeStatistics,
264265
@Nullable List<ItemPath> targetPathsToIgnore,
265266
@Nullable CurrentActivityState<?> activityState,
266267
Task task,

model/smart-impl/src/main/java/com/evolveum/midpoint/smart/impl/DefaultServiceClientImpl.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ public class DefaultServiceClientImpl implements ServiceClient {
4848
private static final String METHOD_SUGGEST_FOCUS_TYPE = "focusType/suggestFocusType";
4949
private static final String METHOD_MATCH_SCHEMA = "matching/matchSchema";
5050
private static final String METHOD_SUGGEST_MAPPING = "mapping/suggestMapping";
51+
private static final String METHOD_SUGGEST_CATEGORICAL_MAPPING = "mapping/suggestCategoricalMapping";
5152

5253
/** The client used to access the remote service. */
5354
private final WebClient webClient;
@@ -137,6 +138,7 @@ private static String getPath(Method method) {
137138
case SUGGEST_FOCUS_TYPE -> URL_PREFIX + METHOD_SUGGEST_FOCUS_TYPE;
138139
case MATCH_SCHEMA -> URL_PREFIX + METHOD_MATCH_SCHEMA;
139140
case SUGGEST_MAPPING -> URL_PREFIX + METHOD_SUGGEST_MAPPING;
141+
case SUGGEST_CATEGORICAL_MAPPING -> URL_PREFIX + METHOD_SUGGEST_CATEGORICAL_MAPPING;
140142
};
141143
}
142144

0 commit comments

Comments
 (0)