From bb453ffd092204fae8afc15f533eeec581d7fc66 Mon Sep 17 00:00:00 2001 From: Roman Pinyazhin Date: Tue, 8 Sep 2026 16:55:35 +0400 Subject: [PATCH 1/2] AI Tools: annotation-based entity exclusion for Data Load tools (5637) --- .../java/io/jmix/aitools/ExcludeFromAi.java | 57 +++++++ .../execution/JpqlExecutionService.java | 44 +++--- .../JpaDomainModelIntrospector.java | 26 +++- ...ecutionServiceExcludeFromAiColumnTest.java | 143 ++++++++++++++++++ ...DomainModelDiscoveryExcludeFromAiTest.java | 125 +++++++++++++++ .../test_support/entity/HiddenBaseEntity.java | 46 ++++++ .../test_support/entity/HiddenEntity.java | 53 +++++++ .../test_support/entity/HiddenSubEntity.java | 39 +++++ .../test_support/entity/sales/Customer.java | 13 ++ 9 files changed, 526 insertions(+), 20 deletions(-) create mode 100644 jmix-aitools/aitools/src/main/java/io/jmix/aitools/ExcludeFromAi.java create mode 100644 jmix-aitools/aitools/src/test/java/execution/JpqlExecutionServiceExcludeFromAiColumnTest.java create mode 100644 jmix-aitools/aitools/src/test/java/introspection/DomainModelDiscoveryExcludeFromAiTest.java create mode 100644 jmix-aitools/aitools/src/test/java/test_support/entity/HiddenBaseEntity.java create mode 100644 jmix-aitools/aitools/src/test/java/test_support/entity/HiddenEntity.java create mode 100644 jmix-aitools/aitools/src/test/java/test_support/entity/HiddenSubEntity.java diff --git a/jmix-aitools/aitools/src/main/java/io/jmix/aitools/ExcludeFromAi.java b/jmix-aitools/aitools/src/main/java/io/jmix/aitools/ExcludeFromAi.java new file mode 100644 index 0000000000..e7c2d064da --- /dev/null +++ b/jmix-aitools/aitools/src/main/java/io/jmix/aitools/ExcludeFromAi.java @@ -0,0 +1,57 @@ +/* + * Copyright 2026 Haulmont. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.jmix.aitools; + +import io.jmix.core.entity.annotation.MetaAnnotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.FIELD; +import static java.lang.annotation.ElementType.METHOD; +import static java.lang.annotation.ElementType.TYPE; + +/** + * Declares that an entity or an attribute must never be exposed to the AI: its data may not reach the + * model, and a generated query may not read it. + *

+ * This is a code-level trust boundary. Unlike the {@code jmix.aitools.dataload.*} include/exclude + * properties, it cannot be undone at deployment time: an annotated element stays hidden regardless of + * property overrides, and no force-include rule can bring it back. Use it for data whose exposure + * decision must survive code review rather than live in overridable configuration — typically + * information that is a normal, usable attribute in the application (personally identifiable + * information, for example) yet must stay out of the model. + *

+ * On a type the entity disappears from domain-model discovery and, having left the introspected + * index, becomes unqueryable. On a field or getter the single attribute is hidden the same way + * while the entity itself stays available. Being a {@link MetaAnnotation}, it propagates to entity + * subclasses and can be overridden through {@code metadata.xml}. + *

+ * Enforced today by the data-load subsystem — the only AI feature that reads the domain model; + * any future feature that reads the domain model is expected to honour it too. Prefer it over + * {@code @Secret} (which hides an attribute everywhere in the framework, not just from the AI) and + * over {@code @SystemLevel} (which only declutters discovery under an overridable flag) when the + * intent is precisely "usable in the application, closed to the AI". + */ +@Documented +@Retention(RetentionPolicy.RUNTIME) +@Target({TYPE, FIELD, METHOD}) +@MetaAnnotation +public @interface ExcludeFromAi { +} diff --git a/jmix-aitools/aitools/src/main/java/io/jmix/aitools/dataload/execution/JpqlExecutionService.java b/jmix-aitools/aitools/src/main/java/io/jmix/aitools/dataload/execution/JpqlExecutionService.java index 1be1fe1d43..58bbc865bf 100644 --- a/jmix-aitools/aitools/src/main/java/io/jmix/aitools/dataload/execution/JpqlExecutionService.java +++ b/jmix-aitools/aitools/src/main/java/io/jmix/aitools/dataload/execution/JpqlExecutionService.java @@ -17,6 +17,7 @@ package io.jmix.aitools.dataload.execution; import io.jmix.aitools.AiToolsDataLoadProperties; +import io.jmix.aitools.ExcludeFromAi; import io.jmix.aitools.dataload.execution.JpqlValidationAndRepairService.OperationResult; import io.jmix.aitools.dataload.validation.JpqlValidationResult; import io.jmix.core.AccessManager; @@ -27,6 +28,7 @@ import io.jmix.core.common.util.Preconditions; import io.jmix.core.entity.KeyValueEntity; import io.jmix.core.metamodel.model.MetaClass; +import io.jmix.core.metamodel.model.MetaProperty; import io.jmix.core.metamodel.model.MetaPropertyPath; import io.jmix.core.metamodel.model.MetadataObject; import io.jmix.core.security.AccessDeniedException; @@ -173,10 +175,11 @@ protected List resolveDeniedSelectedIndexes(String jpqlQuery) { /** * Resolves the positions of the selected columns that must be dropped from the result: those the - * current user is not allowed to read, plus those mapping to a {@link io.jmix.core.annotation.Secret} - * attribute. The secret guard is defense in depth — a secret attribute is already unknown to - * introspection and rejected by JPQL validation — so a secret value is never returned even if a - * query reaches execution through another path. + * current user is not allowed to read, plus those mapping to an attribute hidden from the AI + * (annotated {@link io.jmix.core.annotation.Secret} or {@link ExcludeFromAi}). The hidden-attribute + * guard is defense in depth — such an attribute is already unknown to introspection and rejected by + * JPQL validation — so its value is never returned even if a query reaches execution through another + * path. * * @param jpqlQuery query whose selected columns are being resolved * @return positions (in select-clause order) of the columns to drop, without duplicates @@ -184,54 +187,59 @@ protected List resolveDeniedSelectedIndexes(String jpqlQuery) { */ protected List resolveExcludedSelectedIndexes(String jpqlQuery) { List excluded = new ArrayList<>(resolveDeniedSelectedIndexes(jpqlQuery)); - for (Integer secretIndex : resolveSecretSelectedIndexes(jpqlQuery)) { - if (!excluded.contains(secretIndex)) { - excluded.add(secretIndex); + for (Integer hiddenIndex : resolveHiddenSelectedIndexes(jpqlQuery)) { + if (!excluded.contains(hiddenIndex)) { + excluded.add(hiddenIndex); } } return excluded; } /** - * Resolves the positions of the selected columns that map to a {@link io.jmix.core.annotation.Secret} - * attribute. + * Resolves the positions of the selected columns that map to an attribute hidden from the AI + * (annotated {@link io.jmix.core.annotation.Secret} or {@link ExcludeFromAi}). * * @param jpqlQuery query whose selected columns are being inspected - * @return positions (in select-clause order) of the secret columns, or an empty list when the + * @return positions (in select-clause order) of the hidden columns, or an empty list when the * metadata collaborators are unavailable or the query cannot be parsed */ - protected List resolveSecretSelectedIndexes(String jpqlQuery) { + protected List resolveHiddenSelectedIndexes(String jpqlQuery) { if (metadataTools == null || queryTransformerFactory == null || metadata == null) { return List.of(); } try { QueryParser queryParser = queryTransformerFactory.parser(jpqlQuery); - List secretIndexes = new ArrayList<>(); + List hiddenIndexes = new ArrayList<>(); int selectedIndex = 0; for (QueryParser.QueryPath queryPath : queryParser.getQueryPaths()) { if (queryPath.isSelectedPath()) { - if (isSecretSelectedPath(queryPath)) { - secretIndexes.add(selectedIndex); + if (isHiddenSelectedPath(queryPath)) { + hiddenIndexes.add(selectedIndex); } selectedIndex++; } } - return secretIndexes; + return hiddenIndexes; } catch (RuntimeException e) { return List.of(); } } - protected boolean isSecretSelectedPath(QueryParser.QueryPath queryPath) { + protected boolean isHiddenSelectedPath(QueryParser.QueryPath queryPath) { MetaClass metaClass = metadata.getClass(queryPath.getEntityName()); MetaPropertyPath propertyPath = metaClass.getPropertyPath(queryPath.getPropertyPath()); - return propertyPath != null && metadataTools.isSecret(propertyPath.getMetaProperty()); + if (propertyPath == null) { + return false; + } + MetaProperty metaProperty = propertyPath.getMetaProperty(); + return metadataTools.isSecret(metaProperty) + || metaProperty.getAnnotations().containsKey(ExcludeFromAi.class.getName()); } /** * Returns the result properties that stay in the result, dropping the ones at the excluded select - * positions (denied by security or mapping to a secret attribute). + * positions (denied by security or mapping to an attribute hidden from the AI). * * @param resultProperties result property names in select-clause order * @param excludedSelectedIndexes positions of the columns to drop diff --git a/jmix-aitools/aitools/src/main/java/io/jmix/aitools/dataload/introspection/JpaDomainModelIntrospector.java b/jmix-aitools/aitools/src/main/java/io/jmix/aitools/dataload/introspection/JpaDomainModelIntrospector.java index d7b5c5015e..69e2586686 100644 --- a/jmix-aitools/aitools/src/main/java/io/jmix/aitools/dataload/introspection/JpaDomainModelIntrospector.java +++ b/jmix-aitools/aitools/src/main/java/io/jmix/aitools/dataload/introspection/JpaDomainModelIntrospector.java @@ -17,6 +17,7 @@ package io.jmix.aitools.dataload.introspection; import io.jmix.aitools.AiToolsDataLoadProperties; +import io.jmix.aitools.ExcludeFromAi; import io.jmix.core.MessageTools; import io.jmix.core.Metadata; import io.jmix.core.MetadataTools; @@ -70,7 +71,9 @@ protected void onApplicationStarted(ContextRefreshedEvent event) { *

* Each entity is checked against the rules below, in order; the first matching rule wins: *

    - *
  1. listed in {@code excludeEntities} — excluded (takes precedence over everything else);
  2. + *
  3. annotated {@link ExcludeFromAi} — excluded unconditionally (a code-level trust boundary, + * above every rule below, so no configuration can bring it back);
  4. + *
  5. listed in {@code excludeEntities} — excluded (takes precedence over every include rule);
  6. *
  7. listed in {@code includeEntities} or matching {@code includePackages} — included, * overriding the system-level, DTO and {@code excludePackages} rules below;
  8. *
  9. system-level entity while {@code excludeSystemLevelEntities} is enabled — excluded;
  10. @@ -274,6 +277,11 @@ public EntityDescriptor introspect(MetaClass metaClass) { protected List introspectProperties(MetaClass metaClass) { List propertyDescriptors = new ArrayList<>(); for (MetaProperty property : metaClass.getProperties()) { + // A @ExcludeFromAi attribute is a code-level trust boundary: like @Secret it is never + // indexed, so it is hidden from discovery and rejected by JPQL validation. + if (isExcludedFromAi(property)) { + continue; + } // A @Secret attribute is never indexed: it is hidden from discovery and, because the index // also backs JPQL validation, rejected if a generated query references it anyway. if (metadataTools.isSecret(property)) { @@ -320,7 +328,13 @@ protected boolean isEntityCaptionFallback(MetaClass metaClass, String localizedN } protected boolean shouldInclude(MetaClass metaClass) { - // Explicit exclude takes precedence over everything. + // A @ExcludeFromAi entity is a code-level trust boundary: excluded unconditionally, above every + // rule below (including force-include), so a deployment-time property override cannot expose it. + if (isExcludedFromAi(metaClass)) { + return false; + } + + // Explicit exclude takes precedence over every include rule. if (isExplicitlyExcluded(metaClass)) { return false; } @@ -347,6 +361,14 @@ protected boolean shouldInclude(MetaClass metaClass) { return true; } + protected boolean isExcludedFromAi(MetaClass metaClass) { + return metaClass.getAnnotations().containsKey(ExcludeFromAi.class.getName()); + } + + protected boolean isExcludedFromAi(MetaProperty metaProperty) { + return metaProperty.getAnnotations().containsKey(ExcludeFromAi.class.getName()); + } + protected boolean isExplicitlyIncluded(MetaClass metaClass) { return matchesEntityName(metaClass, dataLoadProperties.getIncludeEntities()) || matchesAnyPackagePrefix(metaClass.getJavaClass().getPackageName(), dataLoadProperties.getIncludePackages()); diff --git a/jmix-aitools/aitools/src/test/java/execution/JpqlExecutionServiceExcludeFromAiColumnTest.java b/jmix-aitools/aitools/src/test/java/execution/JpqlExecutionServiceExcludeFromAiColumnTest.java new file mode 100644 index 0000000000..f5da91b771 --- /dev/null +++ b/jmix-aitools/aitools/src/test/java/execution/JpqlExecutionServiceExcludeFromAiColumnTest.java @@ -0,0 +1,143 @@ +/* + * Copyright 2026 Haulmont. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package execution; + +import io.jmix.aitools.AiToolsDataLoadProperties; +import io.jmix.aitools.dataload.execution.GeneratedJpqlResult; +import io.jmix.aitools.dataload.execution.JpqlExecutionRequest; +import io.jmix.aitools.dataload.execution.JpqlExecutionResult; +import io.jmix.aitools.dataload.execution.JpqlExecutionService; +import io.jmix.aitools.dataload.execution.JpqlParameterConversionService; +import io.jmix.aitools.dataload.execution.JpqlValidationAndRepairService; +import io.jmix.aitools.dataload.execution.JpqlValidationAndRepairService.OperationResult; +import io.jmix.aitools.dataload.validation.JpqlValidationResult; +import io.jmix.core.Metadata; +import io.jmix.core.MetadataTools; +import io.jmix.data.QueryTransformerFactory; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.util.ReflectionTestUtils; +import test_support.AiToolsTestConfiguration; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Verifies the defense-in-depth guard that keeps a {@code @ExcludeFromAi} attribute out of the result + * rows even when the query bypasses JPQL validation (here the validate/repair step is stubbed to + * success). + */ +@ExtendWith(SpringExtension.class) +@ContextConfiguration(classes = AiToolsTestConfiguration.class) +class JpqlExecutionServiceExcludeFromAiColumnTest { + + private static final String HIDDEN_AND_PLAIN_JPQL = + "select c.id as cid, c.piiPhone as cphone from aitls_Customer c"; + private static final String HIDDEN_ONLY_JPQL = + "select c.piiPhone as cphone from aitls_Customer c"; + + @Autowired + QueryTransformerFactory queryTransformerFactory; + @Autowired + Metadata metadata; + @Autowired + MetadataTools metadataTools; + + @Test + @DisplayName("Drops a @ExcludeFromAi column and keeps the readable ones") + void testDropsHiddenColumnAmongSeveral() { + TestJpqlExecutionService service = createService(HIDDEN_AND_PLAIN_JPQL, + List.of(Map.of("cid", 1L, "cphone", "+1-555"))); + + JpqlExecutionResult result = service.execute(new JpqlExecutionRequest( + "Show customers", HIDDEN_AND_PLAIN_JPQL, List.of(), List.of("cid", "cphone"), null, null)); + + assertTrue(result.isExecuted()); + assertEquals(1, result.getRows().size()); + + Map row = result.getRows().get(0); + assertTrue(row.containsKey("cid")); + assertFalse(row.containsKey("cphone")); + assertEquals(1L, row.get("cid")); + } + + @Test + @DisplayName("Returns an empty, non-executed result when the only column is @ExcludeFromAi") + void testReturnsNonExecutedResultWhenOnlyColumnHidden() { + TestJpqlExecutionService service = createService(HIDDEN_ONLY_JPQL, + List.of(Map.of("cphone", "+1-555"))); + + JpqlExecutionResult result = service.execute(new JpqlExecutionRequest( + "Show customers", HIDDEN_ONLY_JPQL, List.of(), List.of("cphone"), null, null)); + + assertFalse(result.isExecuted()); + assertTrue(result.getRows().isEmpty()); + } + + TestJpqlExecutionService createService(String jpql, List> stubbedRows) { + GeneratedJpqlResult generatedResult = new GeneratedJpqlResult(jpql, List.of(), "", List.of()); + + JpqlValidationAndRepairService validateAndRepair = mock(JpqlValidationAndRepairService.class); + when(validateAndRepair.validateAndRepair(any())) + .thenReturn(OperationResult.success(new JpqlExecutionRequest(), generatedResult, + new JpqlValidationResult(true, List.of()), null)); + + JpqlParameterConversionService parameterConversionService = mock(JpqlParameterConversionService.class); + when(parameterConversionService.convert(anyList())).thenReturn(Map.of()); + + TestJpqlExecutionService service = new TestJpqlExecutionService(stubbedRows); + ReflectionTestUtils.setField(service, "validateAndRepair", validateAndRepair); + ReflectionTestUtils.setField(service, "jpqlParameterConversionService", parameterConversionService); + // accessManager is left null: this isolates the @ExcludeFromAi guard from the security column filtering. + ReflectionTestUtils.setField(service, "queryTransformerFactory", queryTransformerFactory); + ReflectionTestUtils.setField(service, "metadata", metadata); + ReflectionTestUtils.setField(service, "metadataTools", metadataTools); + ReflectionTestUtils.setField(service, "dataLoadProperties", + new AiToolsDataLoadProperties(true, true, true, 1, 20, 200, null, null, null, null)); + return service; + } + + static class TestJpqlExecutionService extends JpqlExecutionService { + + private final List> stubbedRows; + + TestJpqlExecutionService(List> stubbedRows) { + this.stubbedRows = stubbedRows; + } + + @Override + protected ExecutionRows executeQuery(JpqlExecutionRequest request, + GeneratedJpqlResult generatedJpqlResult, + Map executionParameters, + Integer maxResults, + Integer firstResult) { + return createExecutionRows(stubbedRows, false); + } + } +} diff --git a/jmix-aitools/aitools/src/test/java/introspection/DomainModelDiscoveryExcludeFromAiTest.java b/jmix-aitools/aitools/src/test/java/introspection/DomainModelDiscoveryExcludeFromAiTest.java new file mode 100644 index 0000000000..e438fc7619 --- /dev/null +++ b/jmix-aitools/aitools/src/test/java/introspection/DomainModelDiscoveryExcludeFromAiTest.java @@ -0,0 +1,125 @@ +/* + * Copyright 2026 Haulmont. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package introspection; + +import io.jmix.aitools.dataload.introspection.JpaDomainModelIntrospector; +import io.jmix.aitools.dataload.introspection.model.EntityDescriptor; +import io.jmix.aitools.dataload.tool.DomainModelDiscoveryTool; +import io.jmix.core.security.SystemAuthenticator; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.ai.chat.model.ToolContext; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import test_support.AiToolsTestConfiguration; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies the {@code @ExcludeFromAi} trust boundary: an annotated entity or attribute is dropped from + * the introspected index unconditionally, the exclusion propagates to entity subclasses, and no + * configuration can bring it back. + */ +class DomainModelDiscoveryExcludeFromAiTest { + + @Nested + @ExtendWith(SpringExtension.class) + @ContextConfiguration(classes = AiToolsTestConfiguration.class) + class DefaultExclusion { + + @Autowired + JpaDomainModelIntrospector introspector; + @Autowired + DomainModelDiscoveryTool tool; + @Autowired + SystemAuthenticator systemAuthenticator; + + @Test + @DisplayName("Drops a @ExcludeFromAi entity from the index, making it unqueryable") + void testEntityDroppedFromIndex() { + assertNull(introspector.getEntityDescriptor("aitls_HiddenEntity")); + // Left the index, so JPQL validation cannot resolve it either. + assertFalse(introspector.containsEntity("aitls_HiddenEntity")); + } + + @Test + @DisplayName("Propagates the exclusion to a subclass of a @ExcludeFromAi entity") + void testExclusionPropagatesToSubclass() { + assertNull(introspector.getEntityDescriptor("aitls_HiddenBaseEntity")); + assertNull(introspector.getEntityDescriptor("aitls_HiddenSubEntity")); + } + + @Test + @DisplayName("Drops a @ExcludeFromAi attribute from the index while the entity stays available") + void testAttributeDroppedFromIndex() { + assertNotNull(introspector.getEntityDescriptor("aitls_Customer")); + assertTrue(introspector.containsProperty("aitls_Customer", "name")); + assertFalse(introspector.containsProperty("aitls_Customer", "piiPhone")); + // Left the index, so a generated query naming it is rejected as an unknown path. + assertFalse(introspector.containsPropertyPath("aitls_Customer", "piiPhone")); + } + + @Test + @DisplayName("Hides a @ExcludeFromAi attribute from the detailed descriptor") + void testAttributeHiddenFromDescriptor() { + systemAuthenticator.begin(); + try { + EntityDescriptor customer = tool.getDomainModelForEntities( + List.of("aitls_Customer"), new ToolContext(Map.of())).stream() + .findFirst() + .orElseThrow(); + assertTrue(hasProperty(customer, "name")); + assertFalse(hasProperty(customer, "piiPhone")); + } finally { + systemAuthenticator.end(); + } + } + } + + @Nested + @ExtendWith(SpringExtension.class) + @ContextConfiguration(classes = AiToolsTestConfiguration.class) + @TestPropertySource(properties = + "jmix.aitools.dataload.include-entities=aitls_HiddenEntity,aitls_HiddenBaseEntity,aitls_HiddenSubEntity") + class ForceIncludeAttempt { + + @Autowired + JpaDomainModelIntrospector introspector; + + @Test + @DisplayName("A force-include cannot bring back a @ExcludeFromAi entity or its subclass") + void testForceIncludeCannotOverrideAnnotation() { + assertNull(introspector.getEntityDescriptor("aitls_HiddenEntity")); + assertNull(introspector.getEntityDescriptor("aitls_HiddenBaseEntity")); + assertNull(introspector.getEntityDescriptor("aitls_HiddenSubEntity")); + } + } + + static boolean hasProperty(EntityDescriptor descriptor, String propertyName) { + return descriptor.getProperties().stream().anyMatch(property -> property.getName().equals(propertyName)); + } +} diff --git a/jmix-aitools/aitools/src/test/java/test_support/entity/HiddenBaseEntity.java b/jmix-aitools/aitools/src/test/java/test_support/entity/HiddenBaseEntity.java new file mode 100644 index 0000000000..fea4a8e4fc --- /dev/null +++ b/jmix-aitools/aitools/src/test/java/test_support/entity/HiddenBaseEntity.java @@ -0,0 +1,46 @@ +/* + * Copyright 2026 Haulmont. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package test_support.entity; + +import io.jmix.aitools.ExcludeFromAi; +import io.jmix.core.metamodel.annotation.JmixEntity; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Inheritance; +import jakarta.persistence.InheritanceType; + +/** + * Base fixture of a {@code @ExcludeFromAi} inheritance hierarchy; its subclass + * {@link HiddenSubEntity} inherits the exclusion via meta-annotation propagation. + */ +@ExcludeFromAi +@Entity(name = "aitls_HiddenBaseEntity") +@Inheritance(strategy = InheritanceType.SINGLE_TABLE) +@JmixEntity +public class HiddenBaseEntity { + + @Id + protected String id; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } +} diff --git a/jmix-aitools/aitools/src/test/java/test_support/entity/HiddenEntity.java b/jmix-aitools/aitools/src/test/java/test_support/entity/HiddenEntity.java new file mode 100644 index 0000000000..da35249ca8 --- /dev/null +++ b/jmix-aitools/aitools/src/test/java/test_support/entity/HiddenEntity.java @@ -0,0 +1,53 @@ +/* + * Copyright 2026 Haulmont. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package test_support.entity; + +import io.jmix.aitools.ExcludeFromAi; +import io.jmix.core.metamodel.annotation.JmixEntity; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; + +/** + * Fixture for a {@code @ExcludeFromAi} entity: an otherwise-includable business entity that must stay + * out of the model regardless of configuration. + */ +@ExcludeFromAi +@Entity(name = "aitls_HiddenEntity") +@JmixEntity +public class HiddenEntity { + + @Id + protected String id; + + protected String name; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } +} diff --git a/jmix-aitools/aitools/src/test/java/test_support/entity/HiddenSubEntity.java b/jmix-aitools/aitools/src/test/java/test_support/entity/HiddenSubEntity.java new file mode 100644 index 0000000000..ed10edeac7 --- /dev/null +++ b/jmix-aitools/aitools/src/test/java/test_support/entity/HiddenSubEntity.java @@ -0,0 +1,39 @@ +/* + * Copyright 2026 Haulmont. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package test_support.entity; + +import io.jmix.core.metamodel.annotation.JmixEntity; +import jakarta.persistence.Entity; + +/** + * Subclass fixture that carries no annotation of its own: it must stay excluded because + * {@code @ExcludeFromAi} on {@link HiddenBaseEntity} propagates to subclasses. + */ +@Entity(name = "aitls_HiddenSubEntity") +@JmixEntity +public class HiddenSubEntity extends HiddenBaseEntity { + + protected String extra; + + public String getExtra() { + return extra; + } + + public void setExtra(String extra) { + this.extra = extra; + } +} diff --git a/jmix-aitools/aitools/src/test/java/test_support/entity/sales/Customer.java b/jmix-aitools/aitools/src/test/java/test_support/entity/sales/Customer.java index bda30ff4f9..bae243f967 100644 --- a/jmix-aitools/aitools/src/test/java/test_support/entity/sales/Customer.java +++ b/jmix-aitools/aitools/src/test/java/test_support/entity/sales/Customer.java @@ -16,6 +16,7 @@ package test_support.entity.sales; +import io.jmix.aitools.ExcludeFromAi; import io.jmix.core.annotation.Secret; import io.jmix.core.entity.annotation.SystemLevel; import io.jmix.core.metamodel.annotation.Comment; @@ -50,6 +51,10 @@ public class Customer { @Column(name = "SECRET_SYSTEM_NOTE") private String secretSystemNote; + @ExcludeFromAi + @Column(name = "PII_PHONE") + private String piiPhone; + @OneToMany(mappedBy = "customer") private List orders; @@ -96,6 +101,14 @@ public void setSecretSystemNote(String secretSystemNote) { this.secretSystemNote = secretSystemNote; } + public String getPiiPhone() { + return piiPhone; + } + + public void setPiiPhone(String piiPhone) { + this.piiPhone = piiPhone; + } + public List getOrders() { return orders; } From 79687e883707157117d903639c421d730f5b5a99 Mon Sep 17 00:00:00 2001 From: Roman Pinyazhin Date: Tue, 8 Sep 2026 17:11:41 +0400 Subject: [PATCH 2/2] AI Tools: annotation-based entity exclusion for Data Load tools (5637) --- .../java/io/jmix/aitools/ExcludeFromAi.java | 27 ++++++------------- 1 file changed, 8 insertions(+), 19 deletions(-) diff --git a/jmix-aitools/aitools/src/main/java/io/jmix/aitools/ExcludeFromAi.java b/jmix-aitools/aitools/src/main/java/io/jmix/aitools/ExcludeFromAi.java index e7c2d064da..009f0b6ef1 100644 --- a/jmix-aitools/aitools/src/main/java/io/jmix/aitools/ExcludeFromAi.java +++ b/jmix-aitools/aitools/src/main/java/io/jmix/aitools/ExcludeFromAi.java @@ -28,26 +28,15 @@ import static java.lang.annotation.ElementType.TYPE; /** - * Declares that an entity or an attribute must never be exposed to the AI: its data may not reach the - * model, and a generated query may not read it. + * Marks an entity or attribute as off-limits to the AI: its data must never reach the model, and a + * generated query may not read it. On a type the whole entity is excluded; on a field or getter only + * that attribute is, and the exclusion is inherited by entity subclasses. *

    - * This is a code-level trust boundary. Unlike the {@code jmix.aitools.dataload.*} include/exclude - * properties, it cannot be undone at deployment time: an annotated element stays hidden regardless of - * property overrides, and no force-include rule can bring it back. Use it for data whose exposure - * decision must survive code review rather than live in overridable configuration — typically - * information that is a normal, usable attribute in the application (personally identifiable - * information, for example) yet must stay out of the model. - *

    - * On a type the entity disappears from domain-model discovery and, having left the introspected - * index, becomes unqueryable. On a field or getter the single attribute is hidden the same way - * while the entity itself stays available. Being a {@link MetaAnnotation}, it propagates to entity - * subclasses and can be overridden through {@code metadata.xml}. - *

    - * Enforced today by the data-load subsystem — the only AI feature that reads the domain model; - * any future feature that reads the domain model is expected to honour it too. Prefer it over - * {@code @Secret} (which hides an attribute everywhere in the framework, not just from the AI) and - * over {@code @SystemLevel} (which only declutters discovery under an overridable flag) when the - * intent is precisely "usable in the application, closed to the AI". + * Unlike the {@code jmix.aitools.dataload.*} include/exclude properties, this is a code-level boundary + * that cannot be overridden at deployment time — use it for data whose exposure decision must + * live in code, such as PII. Prefer it over {@code @Secret} (which hides an attribute everywhere in + * the framework) and {@code @SystemLevel} (an overridable, discovery-only flag) when the intent is + * precisely "usable in the application, closed to the AI". */ @Documented @Retention(RetentionPolicy.RUNTIME)