From 7996c0ca4992e53c344630bddec74c19d1340ce5 Mon Sep 17 00:00:00 2001 From: Konstantin Krivopustov Date: Fri, 18 Sep 2026 17:30:55 +0400 Subject: [PATCH] Dynamic Model: integrate with the Search add-on --- .../ElasticsearchIndexSettingsProvider.java | 9 +- .../component/FullTextFilter.java | 64 +++++++-- .../view/result/SearchResultsView.java | 23 +++- .../io/jmix/searchflowui/messages.properties | 1 + .../FullTextFilterConditionTest.java | 121 +++++++++++++++++ .../SearchResultsViewDetailViewTest.java | 128 ++++++++++++++++++ .../OpenSearchIndexSettingsProvider.java | 9 +- .../search/index/impl/BaseEntityIndexer.java | 9 ++ .../search/index/impl/IndexStateRegistry.java | 13 +- .../index/impl/StartupIndexSynchronizer.java | 15 +- .../mapping/ContributedIndexDefinition.java | 41 ++++++ .../mapping/IndexConfigurationManager.java | 46 +++++-- .../mapping/IndexDefinitionContributor.java | 42 ++++++ .../AnnotatedIndexDefinitionProcessor.java | 67 ++++++++- .../queue/impl/EntityIdsLoaderProvider.java | 3 + .../queue/impl/JpaIndexingQueueManager.java | 11 ++ .../queue/impl/NonJpaEntityIdsLoader.java | 70 ++++++++++ .../listener/DependentEntitiesLoader.java | 38 ++++++ .../index/impl/IndexStateRegistryTest.groovy | 60 ++++++++ .../ContributedIndexDefinitionTest.java | 67 +++++++++ ...butedIndexDefinitionTestConfiguration.java | 57 ++++++++ .../test_mapping_contributed_appended | 16 +++ .../contributed/test_mapping_contributed_only | 10 ++ .../jmix/dynmodelflowui/messages.properties | 2 + .../dynmodelflowui/messages_ru.properties | 2 + .../io/jmix/searchflowui/messages.properties | 1 + .../jmix/searchflowui/messages_ru.properties | 1 + 27 files changed, 880 insertions(+), 46 deletions(-) create mode 100644 jmix-search/search-flowui/src/test/java/io/jmix/searchflowui/component/FullTextFilterConditionTest.java create mode 100644 jmix-search/search-flowui/src/test/java/io/jmix/searchflowui/view/result/SearchResultsViewDetailViewTest.java create mode 100644 jmix-search/search/src/main/java/io/jmix/search/index/mapping/ContributedIndexDefinition.java create mode 100644 jmix-search/search/src/main/java/io/jmix/search/index/mapping/IndexDefinitionContributor.java create mode 100644 jmix-search/search/src/main/java/io/jmix/search/index/queue/impl/NonJpaEntityIdsLoader.java create mode 100644 jmix-search/search/src/test/groovy/io/jmix/search/index/impl/IndexStateRegistryTest.groovy create mode 100644 jmix-search/search/src/test/java/index_definition/ContributedIndexDefinitionTest.java create mode 100644 jmix-search/search/src/test/java/test_support/ContributedIndexDefinitionTestConfiguration.java create mode 100644 jmix-search/search/src/test/resources/index_definition/contributed/test_mapping_contributed_appended create mode 100644 jmix-search/search/src/test/resources/index_definition/contributed/test_mapping_contributed_only diff --git a/jmix-search/search-elasticsearch/src/main/java/io/jmix/searchelasticsearch/index/ElasticsearchIndexSettingsProvider.java b/jmix-search/search-elasticsearch/src/main/java/io/jmix/searchelasticsearch/index/ElasticsearchIndexSettingsProvider.java index 548138658f..45a62175db 100644 --- a/jmix-search/search-elasticsearch/src/main/java/io/jmix/searchelasticsearch/index/ElasticsearchIndexSettingsProvider.java +++ b/jmix-search/search-elasticsearch/src/main/java/io/jmix/searchelasticsearch/index/ElasticsearchIndexSettingsProvider.java @@ -50,7 +50,7 @@ public class ElasticsearchIndexSettingsProvider { protected final ElasticsearchIndexSettingsConfigurationContext context; - protected final Map, IndexSettings> effectiveIndexSettings; + protected final Map effectiveIndexSettings; protected final IndexSettings commonIndexSettings; protected final IndexSettingsAnalysis commonAnalysisSettings; @@ -73,8 +73,11 @@ public ElasticsearchIndexSettingsProvider(List entityClass = indexConfiguration.getEntityClass(); - IndexSettings resultIndexSettings = this.effectiveIndexSettings.get(entityClass); + IndexSettings resultIndexSettings = this.effectiveIndexSettings.get(cacheKey); if (resultIndexSettings == null) { Map, IndexSettings.Builder> indexSettingsBuilders = context.getAllSpecificIndexSettingsBuilders(); IndexSettings entityIndexSettings; @@ -153,7 +156,7 @@ public IndexSettings getSettingsForIndex(IndexConfiguration indexConfiguration) } resultIndexSettings = deserializeIndexSettings(resultIndexSettingsNode.toString()); - this.effectiveIndexSettings.put(entityClass, resultIndexSettings); + this.effectiveIndexSettings.put(cacheKey, resultIndexSettings); } return resultIndexSettings; } diff --git a/jmix-search/search-flowui/src/main/java/io/jmix/searchflowui/component/FullTextFilter.java b/jmix-search/search-flowui/src/main/java/io/jmix/searchflowui/component/FullTextFilter.java index 66e9bd0bea..f7b7c200c3 100644 --- a/jmix-search/search-flowui/src/main/java/io/jmix/searchflowui/component/FullTextFilter.java +++ b/jmix-search/search-flowui/src/main/java/io/jmix/searchflowui/component/FullTextFilter.java @@ -19,8 +19,11 @@ import com.vaadin.flow.component.orderedlayout.HorizontalLayout; import io.jmix.core.Id; import io.jmix.core.IdSerialization; +import io.jmix.core.MetadataTools; +import io.jmix.core.metamodel.model.MetaClass; import io.jmix.core.querycondition.Condition; import io.jmix.core.querycondition.JpqlCondition; +import io.jmix.core.querycondition.PropertyCondition; import io.jmix.flowui.component.filter.SingleFilterComponentBase; import io.jmix.flowui.model.CollectionLoader; import io.jmix.flowui.model.DataLoader; @@ -46,6 +49,7 @@ public class FullTextFilter extends SingleFilterComponentBase { protected String parameterName; protected String searchStrategy; protected SearchProperties searchProperties; + protected MetadataTools metadataTools; protected String correctWhere; @Override @@ -54,6 +58,7 @@ protected void autowireDependencies() { idSerialization = applicationContext.getBean(IdSerialization.class); entitySearcher = applicationContext.getBean(EntitySearcher.class); searchProperties = applicationContext.getBean(SearchProperties.class); + metadataTools = applicationContext.getBean(MetadataTools.class); } @Override @@ -72,10 +77,12 @@ public String getParameterName() { public void setParameterName(String parameterName) { checkState(this.parameterName == null, "Parameter name has already been initialized"); checkNotNullArgument(parameterName); - String where = getQueryCondition().getWhere(); - if (StringUtils.isNotEmpty(where)) { - correctWhere = where.replace("?", ":" + parameterName); - getQueryCondition().setWhere(correctWhere); + if (queryCondition instanceof JpqlCondition jpqlCondition) { + String where = jpqlCondition.getWhere(); + if (StringUtils.isNotEmpty(where)) { + correctWhere = where.replace("?", ":" + parameterName); + jpqlCondition.setWhere(correctWhere); + } } this.parameterName = parameterName; } @@ -89,14 +96,26 @@ protected Condition createQueryCondition() { return fullTextCondition; } - @Override - public JpqlCondition getQueryCondition() { - return (JpqlCondition) queryCondition; + /** + * Creates the condition for an entity whose store cannot run the JPQL of {@link #createQueryCondition()}. Such a + * store supports an {@code IN} condition on the primary key. + * + * @param metaClass meta class of the entity the data loader selects + * @return condition restricting the primary key to the ids returned by the full text search + */ + protected Condition createNonJpaQueryCondition(MetaClass metaClass) { + PropertyCondition condition = PropertyCondition.inList( + metadataTools.getPrimaryKeyName(metaClass), Collections.emptyList()); + // Skippable while empty, so an unfilled filter does not restrict the loader. + condition.setSkipNullOrEmpty(true); + return condition; } @Override protected void updateQueryCondition(@Nullable String newValue) { if (StringUtils.isEmpty(newValue)) { + // An earlier search that found nothing left the condition always false. + enableCorrectWhereClause(); setQueryConditionParameterValue(Collections.emptyList()); } } @@ -106,6 +125,11 @@ public void setDataLoader(DataLoader dataLoader) { if (!(dataLoader instanceof CollectionLoader)) { throw new RuntimeException(FullTextFilter.NAME + " component can only work with CollectionLoader"); } + // The condition is handed to the loader below, so its shape must suit the entity's store. + MetaClass metaClass = ((CollectionLoader) dataLoader).getContainer().getEntityMetaClass(); + if (!metadataTools.isJpaEntity(metaClass)) { + queryCondition = createNonJpaQueryCondition(metaClass); + } super.setDataLoader(dataLoader); registerDataLoaderPreLoadListener((CollectionLoader) dataLoader); } @@ -149,22 +173,38 @@ private List performFullTextSearch(String searchTerm) { /** * When no data is returned by full-text search we must make the condition return false. We set invalid where - * clause for that purpose. + * clause for that purpose. A property condition instead stops being skippable with an empty list of ids. */ private void enableAlwaysFalseWhereClause() { - getQueryCondition().setWhere("1 <> 1"); + if (queryCondition instanceof JpqlCondition jpqlCondition) { + jpqlCondition.setWhere("1 <> 1"); + } else { + ((PropertyCondition) queryCondition).setSkipNullOrEmpty(false); + } } private void enableCorrectWhereClause() { - getQueryCondition().setWhere(correctWhere); + if (queryCondition instanceof JpqlCondition jpqlCondition) { + jpqlCondition.setWhere(correctWhere); + } else { + ((PropertyCondition) queryCondition).setSkipNullOrEmpty(true); + } } private void setQueryConditionParameterValue(List value) { - getQueryCondition().setParameterValuesMap(Collections.singletonMap(parameterName, value)); + if (queryCondition instanceof JpqlCondition jpqlCondition) { + jpqlCondition.setParameterValuesMap(Collections.singletonMap(parameterName, value)); + } else { + ((PropertyCondition) queryCondition).setParameterValue(value); + } } private void clearConditionParameterValuesMap() { - getQueryCondition().setParameterValuesMap(Collections.emptyMap()); + if (queryCondition instanceof JpqlCondition jpqlCondition) { + jpqlCondition.setParameterValuesMap(Collections.emptyMap()); + } else { + ((PropertyCondition) queryCondition).setParameterValue(Collections.emptyList()); + } } @Override diff --git a/jmix-search/search-flowui/src/main/java/io/jmix/searchflowui/view/result/SearchResultsView.java b/jmix-search/search-flowui/src/main/java/io/jmix/searchflowui/view/result/SearchResultsView.java index d2e5f13c7d..b574c8a9b1 100644 --- a/jmix-search/search-flowui/src/main/java/io/jmix/searchflowui/view/result/SearchResultsView.java +++ b/jmix-search/search-flowui/src/main/java/io/jmix/searchflowui/view/result/SearchResultsView.java @@ -37,6 +37,7 @@ import io.jmix.flowui.Notifications; import io.jmix.flowui.UiComponents; import io.jmix.flowui.ViewNavigators; +import io.jmix.flowui.exception.NoSuchViewException; import io.jmix.flowui.kit.component.button.JmixButton; import io.jmix.flowui.theme.StyleUtility; import io.jmix.flowui.view.*; @@ -92,6 +93,8 @@ public class SearchResultsView extends StandardView { protected SearchProperties searchProperties; @Autowired protected MetadataTools metadataTools; + @Autowired + protected ViewRegistry viewRegistry; protected SearchResult searchResult; protected String searchStrategy; @@ -288,6 +291,14 @@ protected JmixButton createInstanceButton(String entityName, SearchResultEntry e protected void openEntityView(SearchResultEntry entry, String entityName) { MetaClass metaClass = metadata.getSession().getClass(entityName); + if (!hasDetailView(metaClass)) { + // A runtime-defined entity may have no detail view at all. + String message = messageBundle.formatMessage("noDetailView", messageTools.getEntityCaption(metaClass)); + notifications.create(message) + .withType(Notifications.Type.WARNING) + .show(); + return; + } Object entity = reloadEntity(metaClass, idSerialization.stringToId(entry.getDocId())); if (OpenMode.DIALOG.equals(searchFieldContext.getOpenMode())) { dialogWindows.detail(this, metaClass.getJavaClass()) @@ -301,6 +312,15 @@ protected void openEntityView(SearchResultEntry entry, String entityName) { } } + protected boolean hasDetailView(MetaClass metaClass) { + try { + viewRegistry.getDetailViewInfo(metaClass); + return true; + } catch (NoSuchViewException e) { + return false; + } + } + protected final ComponentRenderer searchResultRenderer = new ComponentRenderer<>(entry -> { VerticalLayout verticalLayout = uiComponents.create(VerticalLayout.class); verticalLayout.setWidthFull(); @@ -367,7 +387,8 @@ protected String formatFieldCaption(String entityName, String fieldName) { protected Object reloadEntity(MetaClass metaClass, Object entityId) { return dataManager .load(metaClass.getJavaClass()) - .id(entityId) + // A store is given the identifier value; only the JPA store unwraps an Id itself. + .id(entityId instanceof Id id ? id.getValue() : entityId) .fetchPlan(FetchPlan.LOCAL) .one(); } diff --git a/jmix-search/search-flowui/src/main/resources/io/jmix/searchflowui/messages.properties b/jmix-search/search-flowui/src/main/resources/io/jmix/searchflowui/messages.properties index 026464d231..8213a40568 100644 --- a/jmix-search/search-flowui/src/main/resources/io/jmix/searchflowui/messages.properties +++ b/jmix-search/search-flowui/src/main/resources/io/jmix/searchflowui/messages.properties @@ -19,6 +19,7 @@ io.jmix.searchflowui.view.result/fileName=File name io.jmix.searchflowui.view.result/content=File content io.jmix.searchflowui.view.result/noResults=No results io.jmix.searchflowui.view.result/searchDisabled=Search add-on is disabled +io.jmix.searchflowui.view.result/noDetailView=There is no detail view for %s io.jmix.searchflowui.view.filter/fullTextFilterConditionDetailView.title=Full text filter condition detail view io.jmix.searchflowui.view.filter/defaultLabel=Full-text criteria diff --git a/jmix-search/search-flowui/src/test/java/io/jmix/searchflowui/component/FullTextFilterConditionTest.java b/jmix-search/search-flowui/src/test/java/io/jmix/searchflowui/component/FullTextFilterConditionTest.java new file mode 100644 index 0000000000..18c7005819 --- /dev/null +++ b/jmix-search/search-flowui/src/test/java/io/jmix/searchflowui/component/FullTextFilterConditionTest.java @@ -0,0 +1,121 @@ +/* + * 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.searchflowui.component; + +import io.jmix.core.MetadataTools; +import io.jmix.core.metamodel.model.MetaClass; +import io.jmix.core.querycondition.Condition; +import io.jmix.core.querycondition.JpqlCondition; +import io.jmix.core.querycondition.LogicalCondition; +import io.jmix.core.querycondition.PropertyCondition; +import io.jmix.flowui.model.CollectionContainer; +import io.jmix.flowui.model.CollectionLoader; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.Set; + +/** + * The component filters by the ids the search returned. A store that cannot run JPQL rejects the JPQL condition, so + * such an entity is filtered with a property condition on its primary key instead. + */ +public class FullTextFilterConditionTest { + + @Test + void jpaEntityKeepsTheJpqlCondition() { + TestFullTextFilter filter = new TestFullTextFilter(metadataTools(true)); + + filter.setDataLoader(dataLoader()); + + Assertions.assertInstanceOf(JpqlCondition.class, filter.getQueryCondition()); + Assertions.assertEquals("{E}.id in :testParameter", + ((JpqlCondition) filter.getQueryCondition()).getWhere()); + } + + @Test + void nonJpaEntityIsFilteredByAnInListConditionOnThePrimaryKey() { + TestFullTextFilter filter = new TestFullTextFilter(metadataTools(false)); + + filter.setDataLoader(dataLoader()); + + Condition condition = filter.getQueryCondition(); + Assertions.assertInstanceOf(PropertyCondition.class, condition); + PropertyCondition propertyCondition = (PropertyCondition) condition; + Assertions.assertEquals("id", propertyCondition.getProperty()); + Assertions.assertEquals(PropertyCondition.Operation.IN_LIST, propertyCondition.getOperation()); + } + + @Test + void nonJpaConditionIsSkippedWhileTheFilterIsEmpty() { + TestFullTextFilter filter = new TestFullTextFilter(metadataTools(false)); + filter.setDataLoader(dataLoader()); + + // A condition that is skipped leaves the loader unrestricted, which is what an unfilled filter must do. + Assertions.assertNull(filter.getQueryCondition().actualize(Set.of(), false)); + } + + @Test + void clearingTheFilterRestoresTheSkippedNonJpaCondition() { + TestFullTextFilter filter = new TestFullTextFilter(metadataTools(false)); + filter.setDataLoader(dataLoader()); + PropertyCondition condition = (PropertyCondition) filter.getQueryCondition(); + + // A search that found nothing leaves the condition always false: applied, with no ids to match. + condition.setSkipNullOrEmpty(false); + Assertions.assertSame(condition, condition.actualize(Set.of(), false)); + + filter.updateQueryCondition(null); + + Assertions.assertNull(condition.actualize(Set.of(), false), + "clearing the filter must stop the previous empty result from hiding every row"); + } + + private MetadataTools metadataTools(boolean jpaEntity) { + MetadataTools metadataTools = Mockito.mock(MetadataTools.class); + Mockito.when(metadataTools.isJpaEntity(Mockito.any(MetaClass.class))).thenReturn(jpaEntity); + Mockito.when(metadataTools.getPrimaryKeyName(Mockito.any(MetaClass.class))).thenReturn("id"); + return metadataTools; + } + + private CollectionLoader dataLoader() { + CollectionContainer container = Mockito.mock(CollectionContainer.class); + Mockito.when(container.getEntityMetaClass()).thenReturn(Mockito.mock(MetaClass.class)); + + CollectionLoader dataLoader = Mockito.mock(CollectionLoader.class); + Mockito.when(dataLoader.getContainer()).thenAnswer(invocation -> container); + Mockito.when(dataLoader.getCondition()).thenReturn(LogicalCondition.and()); + return dataLoader; + } + + /** + * Builds the component without a Spring context, which {@code initComponent()} would need. + */ + private static class TestFullTextFilter extends FullTextFilter { + + TestFullTextFilter(MetadataTools metadataTools) { + this.metadataTools = metadataTools; + this.queryCondition = createQueryCondition(); + setParameterName("testParameter"); + } + + @Override + public void updateQueryCondition(String newValue) { + super.updateQueryCondition(newValue); + } + } +} diff --git a/jmix-search/search-flowui/src/test/java/io/jmix/searchflowui/view/result/SearchResultsViewDetailViewTest.java b/jmix-search/search-flowui/src/test/java/io/jmix/searchflowui/view/result/SearchResultsViewDetailViewTest.java new file mode 100644 index 0000000000..7cb2346d68 --- /dev/null +++ b/jmix-search/search-flowui/src/test/java/io/jmix/searchflowui/view/result/SearchResultsViewDetailViewTest.java @@ -0,0 +1,128 @@ +/* + * 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.searchflowui.view.result; + +import io.jmix.core.Metadata; +import io.jmix.core.MessageTools; +import io.jmix.core.DataManager; +import io.jmix.core.FluentLoader; +import io.jmix.core.Id; +import io.jmix.core.entity.annotation.JmixId; +import io.jmix.core.metamodel.annotation.JmixEntity; +import org.mockito.ArgumentCaptor; +import java.util.UUID; +import io.jmix.core.metamodel.model.MetaClass; +import io.jmix.core.metamodel.model.Session; +import io.jmix.flowui.DialogWindows; +import io.jmix.flowui.Notifications; +import io.jmix.flowui.ViewNavigators; +import io.jmix.flowui.exception.NoSuchViewException; +import io.jmix.flowui.view.MessageBundle; +import io.jmix.flowui.view.ViewInfo; +import io.jmix.flowui.view.ViewRegistry; +import io.jmix.search.searching.SearchResultEntry; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.Collections; + +public class SearchResultsViewDetailViewTest { + + @Test + void hasDetailViewIsFalseWhenNoDetailViewIsRegistered() { + SearchResultsView view = new SearchResultsView(); + view.viewRegistry = Mockito.mock(ViewRegistry.class); + MetaClass metaClass = Mockito.mock(MetaClass.class); + Mockito.when(view.viewRegistry.getDetailViewInfo(metaClass)).thenThrow(new NoSuchViewException("Alpha.detail")); + + Assertions.assertFalse(view.hasDetailView(metaClass)); + } + + @Test + void reloadEntityPassesTheIdentifierValueRatherThanTheIdWrapper() { + SearchResultsView view = new SearchResultsView(); + view.dataManager = Mockito.mock(DataManager.class); + + FluentLoader loader = Mockito.mock(FluentLoader.class); + FluentLoader.ById byId = Mockito.mock(FluentLoader.ById.class); + Mockito.when(view.dataManager.load(TestEntity.class)).thenReturn(loader); + Mockito.when(loader.id(Mockito.any())).thenReturn(byId); + Mockito.when(byId.fetchPlan(Mockito.anyString())).thenReturn(byId); + + MetaClass metaClass = Mockito.mock(MetaClass.class); + Mockito.when(metaClass.getJavaClass()).thenAnswer(invocation -> TestEntity.class); + + UUID identifier = UUID.randomUUID(); + view.reloadEntity(metaClass, Id.of(identifier, TestEntity.class)); + + // Only the JPA store unwraps an Id itself; every other store is handed whatever this passes. + ArgumentCaptor captor = ArgumentCaptor.forClass(Object.class); + Mockito.verify(loader).id(captor.capture()); + Assertions.assertEquals(identifier, captor.getValue()); + } + + @JmixEntity + static class TestEntity { + + @JmixId + UUID id; + } + + @Test + void hasDetailViewIsTrueWhenDetailViewIsRegistered() { + SearchResultsView view = new SearchResultsView(); + view.viewRegistry = Mockito.mock(ViewRegistry.class); + MetaClass metaClass = Mockito.mock(MetaClass.class); + Mockito.when(view.viewRegistry.getDetailViewInfo(metaClass)).thenReturn(Mockito.mock(ViewInfo.class)); + + Assertions.assertTrue(view.hasDetailView(metaClass)); + } + + @Test + void openEntityViewShowsWarningAndSkipsNavigationWhenNoDetailViewIsRegistered() { + SearchResultsView view = new SearchResultsView(); + view.viewRegistry = Mockito.mock(ViewRegistry.class); + view.metadata = Mockito.mock(Metadata.class); + view.notifications = Mockito.mock(Notifications.class); + view.messageBundle = Mockito.mock(MessageBundle.class); + view.messageTools = Mockito.mock(MessageTools.class); + view.dialogWindows = Mockito.mock(DialogWindows.class); + view.viewNavigators = Mockito.mock(ViewNavigators.class); + + String entityName = "Alpha"; + MetaClass metaClass = Mockito.mock(MetaClass.class); + Session session = Mockito.mock(Session.class); + Mockito.when(view.metadata.getSession()).thenReturn(session); + Mockito.when(session.getClass(entityName)).thenReturn(metaClass); + Mockito.when(view.viewRegistry.getDetailViewInfo(metaClass)).thenThrow(new NoSuchViewException("Alpha.detail")); + Mockito.when(view.messageTools.getEntityCaption(metaClass)).thenReturn("Alpha"); + Mockito.when(view.messageBundle.formatMessage("noDetailView", "Alpha")).thenReturn("There is no detail view for Alpha"); + + Notifications.NotificationBuilder builder = Mockito.mock(Notifications.NotificationBuilder.class); + Mockito.when(view.notifications.create("There is no detail view for Alpha")).thenReturn(builder); + Mockito.when(builder.withType(Notifications.Type.WARNING)).thenReturn(builder); + + SearchResultEntry entry = new SearchResultEntry("docId", "Alpha instance", entityName, Collections.emptyList()); + + view.openEntityView(entry, entityName); + + Mockito.verify(builder).withType(Notifications.Type.WARNING); + Mockito.verify(builder).show(); + Mockito.verifyNoInteractions(view.dialogWindows, view.viewNavigators); + } +} diff --git a/jmix-search/search-opensearch/src/main/java/io/jmix/searchopensearch/index/OpenSearchIndexSettingsProvider.java b/jmix-search/search-opensearch/src/main/java/io/jmix/searchopensearch/index/OpenSearchIndexSettingsProvider.java index 243edbd0ad..c8325c07e2 100644 --- a/jmix-search/search-opensearch/src/main/java/io/jmix/searchopensearch/index/OpenSearchIndexSettingsProvider.java +++ b/jmix-search/search-opensearch/src/main/java/io/jmix/searchopensearch/index/OpenSearchIndexSettingsProvider.java @@ -50,7 +50,7 @@ public class OpenSearchIndexSettingsProvider { protected final OpenSearchIndexSettingsConfigurationContext context; - protected final Map, IndexSettings> effectiveIndexSettings; + protected final Map effectiveIndexSettings; protected final IndexSettings commonIndexSettings; protected final IndexSettingsAnalysis commonAnalysisSettings; @@ -73,8 +73,11 @@ public OpenSearchIndexSettingsProvider(List c } public IndexSettings getSettingsForIndex(IndexConfiguration indexConfiguration) { + // Cached by entity name: a dynamic entity gets a new Java class on every metadata generation, + // while its settings only depend on the configurers, which do not change at runtime. + String cacheKey = indexConfiguration.getEntityName(); Class entityClass = indexConfiguration.getEntityClass(); - IndexSettings resultIndexSettings = this.effectiveIndexSettings.get(entityClass); + IndexSettings resultIndexSettings = this.effectiveIndexSettings.get(cacheKey); if (resultIndexSettings == null) { Map, IndexSettings.Builder> indexSettingsBuilders = context.getAllSpecificIndexSettingsBuilders(); IndexSettings entityIndexSettings; @@ -153,7 +156,7 @@ public IndexSettings getSettingsForIndex(IndexConfiguration indexConfiguration) } resultIndexSettings = deserializeIndexSettings(resultIndexSettingsNode.toString()); - this.effectiveIndexSettings.put(entityClass, resultIndexSettings); + this.effectiveIndexSettings.put(cacheKey, resultIndexSettings); } return resultIndexSettings; } diff --git a/jmix-search/search/src/main/java/io/jmix/search/index/impl/BaseEntityIndexer.java b/jmix-search/search/src/main/java/io/jmix/search/index/impl/BaseEntityIndexer.java index 26e45e929b..35d6fb6ee2 100644 --- a/jmix-search/search/src/main/java/io/jmix/search/index/impl/BaseEntityIndexer.java +++ b/jmix-search/search/src/main/java/io/jmix/search/index/impl/BaseEntityIndexer.java @@ -22,6 +22,7 @@ import io.jmix.core.*; import io.jmix.core.entity.EntityValues; import io.jmix.core.metamodel.model.MetaClass; +import io.jmix.core.querycondition.PropertyCondition; import io.jmix.dynattr.DynAttrQueryHints; import io.jmix.search.SearchProperties; import io.jmix.search.index.EntityIndexer; @@ -237,6 +238,14 @@ protected Map> reloadEntityInstances(Map< .filter(Optional::isPresent) .map(Optional::get) .collect(Collectors.toList()); + } else if (!metadataTools.isJpaEntity(metaClass)) { + // A non-JPA store cannot run the JPQL below; its own query path supports an IN condition. + String primaryKeyName = metadataTools.getPrimaryKeyName(metaClass); + loaded = dataManager + .load(metaClass.getJavaClass()) + .condition(PropertyCondition.inList(primaryKeyName, entityIds)) + .fetchPlan(fetchPlan) + .list(); } else { String primaryKeyName = metadataTools.getPrimaryKeyName(metaClass); String discriminatorCondition = metaClass.getDescendants().isEmpty() ? "" : " and TYPE(e) = " + metaClass.getName(); diff --git a/jmix-search/search/src/main/java/io/jmix/search/index/impl/IndexStateRegistry.java b/jmix-search/search/src/main/java/io/jmix/search/index/impl/IndexStateRegistry.java index c4f5285319..6cbaadde78 100644 --- a/jmix-search/search/src/main/java/io/jmix/search/index/impl/IndexStateRegistry.java +++ b/jmix-search/search/src/main/java/io/jmix/search/index/impl/IndexStateRegistry.java @@ -17,6 +17,8 @@ package io.jmix.search.index.impl; import io.jmix.search.index.mapping.IndexConfigurationManager; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; @@ -37,6 +39,8 @@ @Component("search_IndexStateRegistry") public class IndexStateRegistry { + private static final Logger log = LoggerFactory.getLogger(IndexStateRegistry.class); + protected final Map registry; protected final IndexConfigurationManager indexConfigurationManager; @@ -65,9 +69,9 @@ public void markIndexAsUnavailable(String entityName) { } public List getAllUnavailableIndexedEntities() { - return registry.entrySet().stream() - .filter(entry -> !entry.getValue()) - .map(Map.Entry::getKey) + // Entities indexed by a later metadata generation have no entry yet and are unavailable by default. + return indexConfigurationManager.getAllIndexedEntities().stream() + .filter(entityName -> !isIndexAvailable(entityName)) .collect(Collectors.toList()); } @@ -75,7 +79,8 @@ protected void setRegistryValue(String entityName, boolean value) { if (indexConfigurationManager.isDirectlyIndexed(entityName)) { registry.put(entityName, value); } else { - throw new IllegalArgumentException(String.format("Entity '%s' is not indexed", entityName)); + // A retired entity from an older metadata generation is ignored instead of failing a background operation. + log.debug("Entity '{}' is not indexed, skipping state change", entityName); } } } diff --git a/jmix-search/search/src/main/java/io/jmix/search/index/impl/StartupIndexSynchronizer.java b/jmix-search/search/src/main/java/io/jmix/search/index/impl/StartupIndexSynchronizer.java index fa66077b91..108c82a2b0 100644 --- a/jmix-search/search/src/main/java/io/jmix/search/index/impl/StartupIndexSynchronizer.java +++ b/jmix-search/search/src/main/java/io/jmix/search/index/impl/StartupIndexSynchronizer.java @@ -16,6 +16,7 @@ package io.jmix.search.index.impl; +import io.jmix.core.JmixOrder; import io.jmix.search.SearchProperties; import io.jmix.search.index.IndexManager; import io.jmix.search.index.IndexConfiguration; @@ -24,16 +25,21 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.context.event.ApplicationStartedEvent; +import org.springframework.context.event.EventListener; +import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; -import jakarta.annotation.PostConstruct; - import java.util.ArrayList; import java.util.List; import java.util.Map; /** * Synchronizes search indexes on application startup. + *

+ * Runs at {@link JmixOrder#LOWEST_PRECEDENCE} so that add-ons contributing metadata have published it first: an index + * mapping is built from the metadata, and an incomplete one makes the default {@code create-or-recreate} strategy drop + * the index. */ @Component("search_StartupIndexSynchronizer") public class StartupIndexSynchronizer { @@ -49,8 +55,9 @@ public class StartupIndexSynchronizer { @Autowired protected IndexStateRegistry indexStateRegistry; - @PostConstruct - protected void postConstruct() { + @EventListener(ApplicationStartedEvent.class) + @Order(JmixOrder.LOWEST_PRECEDENCE) + public void onApplicationStarted() { try { if (!searchProperties.isEnabled()) { log.info("Unable to start index synchronization: Search add-on is disabled"); diff --git a/jmix-search/search/src/main/java/io/jmix/search/index/mapping/ContributedIndexDefinition.java b/jmix-search/search/src/main/java/io/jmix/search/index/mapping/ContributedIndexDefinition.java new file mode 100644 index 0000000000..aeed69ce73 --- /dev/null +++ b/jmix-search/search/src/main/java/io/jmix/search/index/mapping/ContributedIndexDefinition.java @@ -0,0 +1,41 @@ +/* + * 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.search.index.mapping; + +import io.jmix.core.common.util.Preconditions; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * An index definition provided by an {@link IndexDefinitionContributor}. + * + * @param entityName Jmix entity name + * @param indexName explicit index name, or {@code null} to derive it from the entity name. Honoured only + * when the contribution creates a new configuration; otherwise the existing index name is + * kept and this one is dropped. + * @param mappingDefinition the fields to index + */ +@NullMarked +public record ContributedIndexDefinition(String entityName, + @Nullable String indexName, + MappingDefinition mappingDefinition) { + + public ContributedIndexDefinition { + Preconditions.checkNotNullArgument(entityName, "entityName is null"); + Preconditions.checkNotNullArgument(mappingDefinition, "mappingDefinition is null"); + } +} diff --git a/jmix-search/search/src/main/java/io/jmix/search/index/mapping/IndexConfigurationManager.java b/jmix-search/search/src/main/java/io/jmix/search/index/mapping/IndexConfigurationManager.java index e7dab1acba..edf0816e6d 100644 --- a/jmix-search/search/src/main/java/io/jmix/search/index/mapping/IndexConfigurationManager.java +++ b/jmix-search/search/src/main/java/io/jmix/search/index/mapping/IndexConfigurationManager.java @@ -59,6 +59,10 @@ *

  • Determining the extent to which entities are indexed
  • *
  • Providing metadata on dependencies between entities in the indexing process
  • * + * + *

    Definitions contributed by {@link IndexDefinitionContributor} beans are merged with the annotated Java ones on + * every (re)build: a contribution for an entity without a Java definition creates a configuration, one for an entity + * that has a Java definition appends its fields. */ @Component("search_IndexConfigurationManager") public class IndexConfigurationManager { @@ -83,6 +87,9 @@ protected State(Registry registry) { @Autowired protected MetadataGenerationManager metadataGenerationManager; + @Autowired(required = false) + protected List indexDefinitionContributors = Collections.emptyList(); + public IndexConfigurationManager(JmixModulesClasspathScanner classpathScanner, AnnotatedIndexDefinitionProcessor indexDefinitionProcessor, InstanceNameProvider instanceNameProvider, @@ -190,7 +197,8 @@ public Optional getIndexConfigurationByIndexNameOpt(String i public Collection getAllIndexedEntities() { State state = getState(); ensureInitialized(state); - return optimisticRead(state, state.registry::getAllIndexedEntities); + // A snapshot, so that a caller iterating the result is unaffected by a concurrent registry rebuild. + return optimisticRead(state, () -> List.copyOf(state.registry.getAllIndexedEntities())); } /** @@ -299,11 +307,27 @@ public Map> getDependenciesMetaDataForDelete(Cl * the definitions specified by the provided class names. */ protected void initializeIndexDefinitions(State state) { - List configurations = new ArrayList<>(); - classNames.forEach(className -> - configurations.add(indexDefinitionProcessor.createIndexConfiguration(className))); - - replaceConfigurations(state, configurations); + Map configurations = new LinkedHashMap<>(); + for (String className : classNames) { + IndexConfiguration configuration = indexDefinitionProcessor.createIndexConfiguration(className); + if (configurations.putIfAbsent(configuration.getEntityName(), configuration) != null) { + log.warn("Multiple Index Definitions are detected for entity '{}'", configuration.getEntityName()); + } + } + for (IndexDefinitionContributor contributor : indexDefinitionContributors) { + Collection definitions = contributor.getIndexDefinitions(); + if (definitions == null) { + throw new IllegalStateException( + "getIndexDefinitions() of " + contributor.getClass().getName() + " returned null " + + "instead of an empty collection"); + } + for (ContributedIndexDefinition definition : definitions) { + configurations.compute(definition.entityName(), (entityName, existing) -> existing == null + ? indexDefinitionProcessor.createIndexConfiguration(definition) + : indexDefinitionProcessor.appendContributedFields(existing, definition)); + } + } + replaceConfigurations(state, new ArrayList<>(configurations.values())); } /** @@ -512,13 +536,9 @@ boolean isEntityClassRegistered(Class entityClass) { private void registerInMainRegistries(IndexConfiguration indexConfiguration) { String entityName = indexConfiguration.getEntityName(); - if (indexConfigurationsByEntityName.containsKey(entityName)) { - log.warn("Multiple Index Definitions are detected for entity '{}'", entityName); - } else { - indexConfigurationsByEntityName.put(entityName, indexConfiguration); - indexConfigurationsByIndexName.put(indexConfiguration.getIndexName(), indexConfiguration); - registeredEntityClasses.addAll(indexConfiguration.getAffectedEntityClasses()); - } + indexConfigurationsByEntityName.put(entityName, indexConfiguration); + indexConfigurationsByIndexName.put(indexConfiguration.getIndexName(), indexConfiguration); + registeredEntityClasses.addAll(indexConfiguration.getAffectedEntityClasses()); } private void processProperty(MetaPropertyPath propertyPath) { diff --git a/jmix-search/search/src/main/java/io/jmix/search/index/mapping/IndexDefinitionContributor.java b/jmix-search/search/src/main/java/io/jmix/search/index/mapping/IndexDefinitionContributor.java new file mode 100644 index 0000000000..f59f0dbe96 --- /dev/null +++ b/jmix-search/search/src/main/java/io/jmix/search/index/mapping/IndexDefinitionContributor.java @@ -0,0 +1,42 @@ +/* + * 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.search.index.mapping; + +import org.jspecify.annotations.NullMarked; + +import java.util.Collection; + +/** + * Contributes index definitions that are not declared as annotated Java interfaces, for example + * definitions derived from runtime metadata. Implementations are Spring beans. They are consulted + * every time {@link IndexConfigurationManager} builds the index configurations of a metadata + * generation, so a contribution must describe the entities and properties of the generation that is + * pinned or current for the calling thread. + */ +@NullMarked +public interface IndexDefinitionContributor { + + /** + * Returns the definitions to add. A definition for an entity that also has an annotated Java + * definition is appended to it: contributed fields that are not already mapped are added, fields + * already mapped by the Java definition are kept as they are. A definition for an entity without a + * Java definition creates a new index configuration. + * + * @return contributed definitions, empty when there is nothing to contribute + */ + Collection getIndexDefinitions(); +} diff --git a/jmix-search/search/src/main/java/io/jmix/search/index/mapping/processor/impl/AnnotatedIndexDefinitionProcessor.java b/jmix-search/search/src/main/java/io/jmix/search/index/mapping/processor/impl/AnnotatedIndexDefinitionProcessor.java index 119f3ca17c..ac95c73dfc 100644 --- a/jmix-search/search/src/main/java/io/jmix/search/index/mapping/processor/impl/AnnotatedIndexDefinitionProcessor.java +++ b/jmix-search/search/src/main/java/io/jmix/search/index/mapping/processor/impl/AnnotatedIndexDefinitionProcessor.java @@ -126,6 +126,60 @@ public IndexConfiguration createIndexConfiguration(String className) { return indexConfiguration; } + /** + * Creates an index configuration for an entity that has no annotated Java definition. + * + * @param definition contributed definition + * @return index configuration + */ + public IndexConfiguration createIndexConfiguration(ContributedIndexDefinition definition) { + MetaClass metaClass = metadata.getClass(definition.entityName()); + String indexName = createIndexName(definition.indexName(), metaClass); + ExtendedSearchSettings extendedSearchSettings = ExtendedSearchSettings.empty(); + Map fields = processMappingDefinition( + metaClass, definition.mappingDefinition(), extendedSearchSettings); + IndexMappingConfiguration mapping = new IndexMappingConfiguration( + metaClass, fields, createDisplayedNameDescriptor(metaClass)); + return new IndexConfiguration( + metaClass.getName(), + metaClass.getJavaClass(), + indexName, + mapping, + getAffectedEntityClasses(mapping), + obj -> true, + extendedSearchSettings); + } + + /** + * Adds the fields of a contributed definition to an existing configuration. Fields already present + * in the existing configuration win over contributed ones with the same name. + * + * @param base configuration built from the annotated Java definition + * @param definition contributed definition for the same entity + * @return a new index configuration with the merged fields + */ + public IndexConfiguration appendContributedFields(IndexConfiguration base, ContributedIndexDefinition definition) { + if (definition.indexName() != null) { + log.warn("Index name '{}' contributed for entity '{}' is dropped, the entity is already mapped " + + "to index '{}'", definition.indexName(), definition.entityName(), base.getIndexName()); + } + IndexMappingConfiguration baseMapping = base.getMapping(); + MetaClass metaClass = baseMapping.getEntityMetaClass(); + Map fields = new HashMap<>(baseMapping.getFields()); + processMappingDefinition(metaClass, definition.mappingDefinition(), base.getExtendedSearchSettings()) + .forEach(fields::putIfAbsent); + IndexMappingConfiguration mapping = new IndexMappingConfiguration( + metaClass, fields, baseMapping.getDisplayedNameDescriptor()); + return new IndexConfiguration( + base.getEntityName(), + base.getEntityClass(), + base.getIndexName(), + mapping, + getAffectedEntityClasses(mapping), + base.getIndexablePredicate(), + base.getExtendedSearchSettings()); + } + protected Class resolveClass(String className) { try { return Class.forName(className); @@ -173,12 +227,13 @@ protected ParsedIndexDefinition parseIndexDefinition(Class indexDefinitionCla } protected String createIndexName(ParsedIndexDefinition parsedIndexDefinition) { - String indexName; - if (StringUtils.isNotEmpty(parsedIndexDefinition.getIndexName())) { - indexName = parsedIndexDefinition.getIndexName().toLowerCase(); - } else { - indexName = searchProperties.getSearchIndexNamePrefix() + parsedIndexDefinition.getMetaClass().getName(); - } + return createIndexName(parsedIndexDefinition.getIndexName(), parsedIndexDefinition.getMetaClass()); + } + + protected String createIndexName(@Nullable String explicitIndexName, MetaClass metaClass) { + String indexName = StringUtils.isNotEmpty(explicitIndexName) + ? explicitIndexName + : searchProperties.getSearchIndexNamePrefix() + metaClass.getName(); return indexName.toLowerCase(); } diff --git a/jmix-search/search/src/main/java/io/jmix/search/index/queue/impl/EntityIdsLoaderProvider.java b/jmix-search/search/src/main/java/io/jmix/search/index/queue/impl/EntityIdsLoaderProvider.java index acf5f6ba6e..b0448b7b2f 100644 --- a/jmix-search/search/src/main/java/io/jmix/search/index/queue/impl/EntityIdsLoaderProvider.java +++ b/jmix-search/search/src/main/java/io/jmix/search/index/queue/impl/EntityIdsLoaderProvider.java @@ -55,6 +55,9 @@ public EntityIdsLoaderProvider(Metadata metadata, public EntityIdsLoader getLoader(String entityName) { MetaClass entityClass = metadata.getClass(entityName); + if (!metadataTools.isJpaEntity(entityClass)) { + return registry.get(NonJpaEntityIdsLoader.class); + } Store store = entityClass.getStore(); String dbType = dbmsType.getType(store.getName()); if ("ORACLE".equalsIgnoreCase(dbType)) { diff --git a/jmix-search/search/src/main/java/io/jmix/search/index/queue/impl/JpaIndexingQueueManager.java b/jmix-search/search/src/main/java/io/jmix/search/index/queue/impl/JpaIndexingQueueManager.java index ed171a3643..2b89542b11 100644 --- a/jmix-search/search/src/main/java/io/jmix/search/index/queue/impl/JpaIndexingQueueManager.java +++ b/jmix-search/search/src/main/java/io/jmix/search/index/queue/impl/JpaIndexingQueueManager.java @@ -18,6 +18,7 @@ import io.jmix.core.*; import io.jmix.core.common.util.Preconditions; +import io.jmix.core.entity.EntityValues; import io.jmix.core.metamodel.model.MetaClass; import io.jmix.core.security.SystemAuthenticator; import io.jmix.data.StoreAwareLocator; @@ -392,6 +393,16 @@ protected List loadRawIds(MetaClass metaClass) { throw new IllegalArgumentException(String.format("Unable to enqueue instances of entity '%s' - entity doesn't have primary key", entityName)); } + if (!metadataTools.isJpaEntity(metaClass)) { + return dataManager.load(metaClass.getJavaClass()) + .all() + .fetchPlanProperties(primaryKeyName) + .list() + .stream() + .map(EntityValues::getId) + .toList(); + } + List rawIds; TransactionTemplate transactionTemplate = storeAwareLocator.getTransactionTemplate(metaClass.getStore().getName()); transactionTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED); diff --git a/jmix-search/search/src/main/java/io/jmix/search/index/queue/impl/NonJpaEntityIdsLoader.java b/jmix-search/search/src/main/java/io/jmix/search/index/queue/impl/NonJpaEntityIdsLoader.java new file mode 100644 index 0000000000..4d69b9bdff --- /dev/null +++ b/jmix-search/search/src/main/java/io/jmix/search/index/queue/impl/NonJpaEntityIdsLoader.java @@ -0,0 +1,70 @@ +/* + * 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.search.index.queue.impl; + +import io.jmix.core.FluentLoader; +import io.jmix.core.Sort; +import io.jmix.core.ValueLoadContext; +import io.jmix.core.entity.EntityValues; +import io.jmix.core.entity.KeyValueEntity; +import io.jmix.core.metamodel.model.MetaClass; +import io.jmix.core.metamodel.model.MetaProperty; +import io.jmix.core.querycondition.PropertyCondition; +import io.jmix.search.index.queue.entity.EnqueueingSession; +import org.jspecify.annotations.NullMarked; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * Loads ids of an entity that is not a JPA entity, for example a dynamic entity stored in a custom + * data store. Pages through the entity ordered by the session's ordering property using the + * store's own condition support instead of JPQL. + */ +@NullMarked +@Component("search_NonJpaEntityIdsLoader") +public class NonJpaEntityIdsLoader extends OrderBasedEntityIdsLoader { + + @Override + public ResultHolder loadNextIds(EnqueueingSession session, int batchSize) { + MetaClass metaClass = metadata.getClass(session.getEntityName()); + MetaProperty orderingProperty = metaClass.getProperty(session.getOrderingProperty()); + String orderingPropertyName = orderingProperty.getName(); + Object lastProcessedValue = convertRawValue(orderingProperty, session.getLastProcessedValue()); + + FluentLoader.ByCondition loader = lastProcessedValue == null + ? dataManager.load(metaClass.getJavaClass()).all() + : dataManager.load(metaClass.getJavaClass()) + .condition(PropertyCondition.greater(orderingPropertyName, lastProcessedValue)); + List entities = loader + .fetchPlanProperties(orderingPropertyName) + .sort(Sort.by(orderingPropertyName)) + .maxResults(batchSize) + .list(); + + List ids = entities.stream().map(EntityValues::getId).toList(); + Object lastLoadedValue = entities.isEmpty() + ? null + : EntityValues.getValue(entities.get(entities.size() - 1), orderingPropertyName); + return new ResultHolder(ids, lastLoadedValue); + } + + @Override + protected List loadValues(ValueLoadContext valueLoadContext) { + throw new UnsupportedOperationException("Value loading is not used for non-JPA entities"); + } +} diff --git a/jmix-search/search/src/main/java/io/jmix/search/listener/DependentEntitiesLoader.java b/jmix-search/search/src/main/java/io/jmix/search/listener/DependentEntitiesLoader.java index 48209043fb..fee36f4551 100644 --- a/jmix-search/search/src/main/java/io/jmix/search/listener/DependentEntitiesLoader.java +++ b/jmix-search/search/src/main/java/io/jmix/search/listener/DependentEntitiesLoader.java @@ -20,7 +20,9 @@ import io.jmix.core.Id; import io.jmix.core.MetadataTools; 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.querycondition.PropertyCondition; import io.jmix.data.PersistenceHints; import io.jmix.search.index.impl.dynattr.DynamicAttributesSupport; import io.jmix.search.listener.dynattr.DynamicAttributeReferenceFieldResolver; @@ -77,6 +79,13 @@ public Set> loadDependentEntityIds(Id targetEntityId, for (MetaPropertyPath propertyPath : properties) { log.debug("Load entities '{}' dependent via property '{}'", entityName, propertyPath); + if (isNonJpaProperty(propertyPath)) { + // The JPQL below does not compile for such a property, and a failed query inside the save + // transaction marks it rollback-only. + result.addAll(loadDependentEntityIdsByCondition(metaClass, propertyPath, targetEntityId)); + continue; + } + DependentEntitiesQuery dependentEntitiesQuery = new DependentEntitiesQueryBuilder(metadataTools, dynamicAttributeReferenceFieldResolver, dynamicAttributesSupport) .loadEntity(metaClass) .byProperty(propertyPath) @@ -98,6 +107,35 @@ public Set> loadDependentEntityIds(Id targetEntityId, return result; } + /** + * A {@code jmix-dynattr} attribute is not JPA-mapped either, but it has a query of its own and is excluded here. + */ + protected boolean isNonJpaProperty(MetaPropertyPath propertyPath) { + return !dynamicAttributesSupport.isDynamicAttribute(propertyPath) && !metadataTools.isJpa(propertyPath); + } + + protected List> loadDependentEntityIdsByCondition(MetaClass metaClass, + MetaPropertyPath propertyPath, + Id targetEntityId) { + for (MetaProperty property : propertyPath.getMetaProperties()) { + if (property.getRange().getCardinality().isMany()) { + log.warn("Dependent entities '{}' via non-JPA collection property '{}' are not reloaded: " + + "a collection cannot be expressed as a property condition", metaClass.getName(), propertyPath); + return List.of(); + } + } + List> refObjectIds = dataManager.load(metaClass.getJavaClass()) + .condition(PropertyCondition.equal(propertyPath.toPathString(), targetEntityId.getValue())) + .hint(PersistenceHints.SOFT_DELETION, false) + .joinTransaction(true) + .list() + .stream() + .map(Id::of) + .collect(Collectors.toList()); + log.debug("Loaded primary keys of dependent references by condition ({}): {}", refObjectIds.size(), refObjectIds); + return refObjectIds; + } + protected List> performLoadingDependentEntityIds(MetaClass metaClass, DependentEntitiesQuery dependentEntitiesQuery) { return dataManager.load(metaClass.getJavaClass()) .query(dependentEntitiesQuery.query()) diff --git a/jmix-search/search/src/test/groovy/io/jmix/search/index/impl/IndexStateRegistryTest.groovy b/jmix-search/search/src/test/groovy/io/jmix/search/index/impl/IndexStateRegistryTest.groovy new file mode 100644 index 0000000000..f5883b9db9 --- /dev/null +++ b/jmix-search/search/src/test/groovy/io/jmix/search/index/impl/IndexStateRegistryTest.groovy @@ -0,0 +1,60 @@ +/* + * 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.search.index.impl + +import io.jmix.search.index.mapping.IndexConfigurationManager +import spock.lang.Specification + +class IndexStateRegistryTest extends Specification { + + def "an entity indexed after startup is unavailable until it is marked available"() { + given: + def manager = Mock(IndexConfigurationManager) + // The constructor sees one entity; a later metadata generation adds a second one. + manager.getAllIndexedEntities() >>> [["a"], ["a", "b"], ["a", "b"], ["a", "b"]] + manager.isDirectlyIndexed(_ as String) >> true + def registry = new IndexStateRegistry(manager) + + expect: + registry.getAllUnavailableIndexedEntities() == ["a", "b"] + !registry.isIndexAvailable("b") + + when: + registry.markIndexAsAvailable("b") + + then: + registry.getAllUnavailableIndexedEntities() == ["a"] + registry.isIndexAvailable("b") + } + + def "marking an entity not indexed in the current metadata generation as available is ignored"() { + given: + def manager = Mock(IndexConfigurationManager) + manager.getAllIndexedEntities() >> ["a"] + manager.isDirectlyIndexed("a") >> true + manager.isDirectlyIndexed("retired") >> false + def registry = new IndexStateRegistry(manager) + + when: + registry.markIndexAsAvailable("retired") + + then: + noExceptionThrown() + !registry.getIndexAvailabilityStates().containsKey("retired") + !registry.isIndexAvailable("retired") + } +} diff --git a/jmix-search/search/src/test/java/index_definition/ContributedIndexDefinitionTest.java b/jmix-search/search/src/test/java/index_definition/ContributedIndexDefinitionTest.java new file mode 100644 index 0000000000..08d9eb76da --- /dev/null +++ b/jmix-search/search/src/test/java/index_definition/ContributedIndexDefinitionTest.java @@ -0,0 +1,67 @@ +/* + * 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 index_definition; + +import com.fasterxml.jackson.databind.JsonNode; +import io.jmix.search.index.IndexConfiguration; +import io.jmix.search.index.mapping.IndexConfigurationManager; +import org.hamcrest.MatcherAssert; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.Assertions; +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 test_support.ContributedIndexDefinitionTestConfiguration; +import test_support.TestJsonUtils; +import test_support.entity.TestReferenceEntity; +import test_support.entity.TestSimpleRootEntity; +import test_support.index_definition.IndexConfigurationMatcher; + +@ExtendWith(SpringExtension.class) +@ContextConfiguration(classes = {ContributedIndexDefinitionTestConfiguration.class}) +public class ContributedIndexDefinitionTest { + + @Autowired + IndexConfigurationManager indexConfigurationManager; + + @Test + void contributedDefinitionCreatesConfigurationForEntityWithoutJavaDefinition() { + IndexConfiguration configuration = indexConfigurationManager.getIndexConfigurationByEntityName("test_ReferenceEntity"); + + JsonNode expectedMapping = TestJsonUtils.readJsonFromFile("index_definition/contributed/test_mapping_contributed_only"); + MatcherAssert.assertThat(configuration, IndexConfigurationMatcher.configureWith( + "test_ReferenceEntity", "search_index_test_referenceentity", TestReferenceEntity.class, expectedMapping)); + } + + @Test + void contributedFieldsAreAppendedToJavaDefinition() { + IndexConfiguration configuration = indexConfigurationManager.getIndexConfigurationByEntityName("test_SimpleRootEntity"); + + JsonNode expectedMapping = TestJsonUtils.readJsonFromFile("index_definition/contributed/test_mapping_contributed_appended"); + MatcherAssert.assertThat(configuration, IndexConfigurationMatcher.configureWith( + "test_SimpleRootEntity", "search_index_test_simplerootentity", TestSimpleRootEntity.class, expectedMapping)); + } + + @Test + void contributedEntityIsDirectlyIndexed() { + Assertions.assertTrue(indexConfigurationManager.isDirectlyIndexed("test_ReferenceEntity")); + MatcherAssert.assertThat(indexConfigurationManager.getAllIndexedEntities(), + Matchers.containsInAnyOrder("test_SimpleRootEntity", "test_ReferenceEntity")); + } +} diff --git a/jmix-search/search/src/test/java/test_support/ContributedIndexDefinitionTestConfiguration.java b/jmix-search/search/src/test/java/test_support/ContributedIndexDefinitionTestConfiguration.java new file mode 100644 index 0000000000..7b45fc76b4 --- /dev/null +++ b/jmix-search/search/src/test/java/test_support/ContributedIndexDefinitionTestConfiguration.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 test_support; + +import io.jmix.search.index.mapping.ContributedIndexDefinition; +import io.jmix.search.index.mapping.IndexDefinitionContributor; +import io.jmix.search.index.mapping.MappingDefinition; +import io.jmix.search.index.mapping.StaticAttributesGroupConfiguration; +import io.jmix.search.index.mapping.strategy.impl.AutoMappingStrategy; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import test_support.index_definition.common.TestIncludeSpecificLocalPropertiesIndexDefinition; + +import java.util.List; + +@Configuration +@Import({IndexDefinitionProcessingTestConfiguration.class}) +public class ContributedIndexDefinitionTestConfiguration { + + @Bean + public TestAutoDetectableIndexDefinitionScope testAutoDetectableIndexDefinitionScope() { + return TestAutoDetectableIndexDefinitionScope.builder() + .classes(TestIncludeSpecificLocalPropertiesIndexDefinition.class) + .build(); + } + + @Bean + public IndexDefinitionContributor testIndexDefinitionContributor() { + return () -> List.of( + new ContributedIndexDefinition("test_SimpleRootEntity", null, mapping("secondTextValue")), + new ContributedIndexDefinition("test_ReferenceEntity", null, mapping("textValue"))); + } + + private static MappingDefinition mapping(String... properties) { + return MappingDefinition.builder() + .addStaticAttributesGroup(StaticAttributesGroupConfiguration.builder() + .includeProperties(properties) + .withFieldMappingStrategyClass(AutoMappingStrategy.class) + .build()) + .build(); + } +} diff --git a/jmix-search/search/src/test/resources/index_definition/contributed/test_mapping_contributed_appended b/jmix-search/search/src/test/resources/index_definition/contributed/test_mapping_contributed_appended new file mode 100644 index 0000000000..3e723b5b9a --- /dev/null +++ b/jmix-search/search/src/test/resources/index_definition/contributed/test_mapping_contributed_appended @@ -0,0 +1,16 @@ +{ + "properties": { + "firstTextValue": { + "type": "text" + }, + "name": { + "type": "text" + }, + "secondTextValue": { + "type": "text" + }, + "_instance_name": { + "type": "text" + } + } +} \ No newline at end of file diff --git a/jmix-search/search/src/test/resources/index_definition/contributed/test_mapping_contributed_only b/jmix-search/search/src/test/resources/index_definition/contributed/test_mapping_contributed_only new file mode 100644 index 0000000000..a94bed04d5 --- /dev/null +++ b/jmix-search/search/src/test/resources/index_definition/contributed/test_mapping_contributed_only @@ -0,0 +1,10 @@ +{ + "properties": { + "textValue": { + "type": "text" + }, + "_instance_name": { + "type": "text" + } + } +} \ No newline at end of file diff --git a/jmix-translations/content/io/jmix/dynmodelflowui/messages.properties b/jmix-translations/content/io/jmix/dynmodelflowui/messages.properties index 38f2a6892b..bb37b1580a 100644 --- a/jmix-translations/content/io/jmix/dynmodelflowui/messages.properties +++ b/jmix-translations/content/io/jmix/dynmodelflowui/messages.properties @@ -61,6 +61,7 @@ io.jmix.dynmodelflowui.view.dynamicview/menuResourceRolesField.helperText=Roles io.jmix.dynmodelflowui.view.attribute/dynamicAttributeModelDetailView.title=Attribute io.jmix.dynmodelflowui.view.attribute/attributeTypeField.label=Attribute type +io.jmix.dynmodelflowui.view.attribute/searchableField.indexedByApplicationTitle=Already indexed by the application's search index definition io.jmix.dynmodelflowui.view.attribute/attributeTabSheet.generalTab.label=General io.jmix.dynmodelflowui.view.attribute/attributeTabSheet.validationTab.label=Validation io.jmix.dynmodelflowui.view.attribute/attributeTabSheet.rolesTab.label=Roles @@ -147,6 +148,7 @@ io.jmix.dynmodelflowui.model/DynamicAttributeModel.messages=Messages io.jmix.dynmodelflowui.model/DynamicAttributeModel.name=Name io.jmix.dynmodelflowui.model/DynamicAttributeModel.required=Required io.jmix.dynmodelflowui.model/DynamicAttributeModel.resourceRoles=Resource roles +io.jmix.dynmodelflowui.model/DynamicAttributeModel.searchable=Searchable io.jmix.dynmodelflowui.model/DynamicAttributeModel.unique=Unique io.jmix.dynmodelflowui.model/DynamicAttributeModel.validationConstraints=Validation constraints io.jmix.dynmodelflowui.model/DynamicAttributeResourceRolesModel=Dynamic attribute resource roles diff --git a/jmix-translations/content/io/jmix/dynmodelflowui/messages_ru.properties b/jmix-translations/content/io/jmix/dynmodelflowui/messages_ru.properties index 308271f236..c7adde4090 100644 --- a/jmix-translations/content/io/jmix/dynmodelflowui/messages_ru.properties +++ b/jmix-translations/content/io/jmix/dynmodelflowui/messages_ru.properties @@ -61,6 +61,7 @@ io.jmix.dynmodelflowui.view.dynamicview/menuResourceRolesField.helperText=Рол io.jmix.dynmodelflowui.view.attribute/dynamicAttributeModelDetailView.title=Атрибут io.jmix.dynmodelflowui.view.attribute/attributeTypeField.label=Тип атрибута +io.jmix.dynmodelflowui.view.attribute/searchableField.indexedByApplicationTitle=Уже индексируется определением поискового индекса приложения io.jmix.dynmodelflowui.view.attribute/attributeTabSheet.generalTab.label=Общие io.jmix.dynmodelflowui.view.attribute/attributeTabSheet.validationTab.label=Валидация io.jmix.dynmodelflowui.view.attribute/attributeTabSheet.rolesTab.label=Роли @@ -147,6 +148,7 @@ io.jmix.dynmodelflowui.model/DynamicAttributeModel.messages=Сообщения io.jmix.dynmodelflowui.model/DynamicAttributeModel.name=Имя io.jmix.dynmodelflowui.model/DynamicAttributeModel.required=Обязательный io.jmix.dynmodelflowui.model/DynamicAttributeModel.resourceRoles=Ресурсные роли +io.jmix.dynmodelflowui.model/DynamicAttributeModel.searchable=Индексируемый io.jmix.dynmodelflowui.model/DynamicAttributeModel.unique=Уникальный io.jmix.dynmodelflowui.model/DynamicAttributeModel.validationConstraints=Ограничения валидации io.jmix.dynmodelflowui.model/DynamicAttributeResourceRolesModel=Ресурсные роли динамического атрибута diff --git a/jmix-translations/content/io/jmix/searchflowui/messages.properties b/jmix-translations/content/io/jmix/searchflowui/messages.properties index 11c3690294..64566ae4ad 100644 --- a/jmix-translations/content/io/jmix/searchflowui/messages.properties +++ b/jmix-translations/content/io/jmix/searchflowui/messages.properties @@ -19,6 +19,7 @@ io.jmix.searchflowui.view.result/fileName=File name io.jmix.searchflowui.view.result/content=File content io.jmix.searchflowui.view.result/noResults=No results io.jmix.searchflowui.view.result/searchDisabled=Search add-on is disabled +io.jmix.searchflowui.view.result/noDetailView=There is no detail view for %s io.jmix.searchflowui.view.filter/fullTextFilterConditionDetailView.title=Full text filter condition detail view io.jmix.searchflowui.view.filter/defaultLabel=Full-text criteria diff --git a/jmix-translations/content/io/jmix/searchflowui/messages_ru.properties b/jmix-translations/content/io/jmix/searchflowui/messages_ru.properties index 302b2bae68..f637e43a8d 100644 --- a/jmix-translations/content/io/jmix/searchflowui/messages_ru.properties +++ b/jmix-translations/content/io/jmix/searchflowui/messages_ru.properties @@ -19,6 +19,7 @@ io.jmix.searchflowui.view.result/fileName=Имя файла io.jmix.searchflowui.view.result/content=Содержимое файла io.jmix.searchflowui.view.result/noResults=Ничего не найдено io.jmix.searchflowui.view.result/searchDisabled=Дополнение Search выключено +io.jmix.searchflowui.view.result/noDetailView=Для %s нет экрана редактирования io.jmix.searchflowui.view.filter/fullTextFilterConditionDetailView.title=Полнотекстовый фильтр io.jmix.searchflowui.view.filter/defaultLabel=Полнотекстовый критерий