From a7b04a0470185ed08f33b100ef7dce725aa92b52 Mon Sep 17 00:00:00 2001 From: Paurikova2 Date: Thu, 19 Mar 2026 13:35:51 +0100 Subject: [PATCH] Add title_sort indexing for communities/collections --- .../app/util/SubmissionConfigReader.java | 22 ++- .../dspace/content/CollectionServiceImpl.java | 11 +- .../org/dspace/discovery/SearchService.java | 2 +- .../org/dspace/discovery/SolrServiceImpl.java | 13 +- .../CollectionIndexFactoryImpl.java | 3 + .../CommunityIndexFactoryImpl.java | 3 + .../converter/AInprogressItemConverter.java | 9 +- .../rest/converter/WorkflowItemConverter.java | 3 +- .../converter/WorkspaceItemConverter.java | 3 +- .../rest/model/query/RestSearchOperator.java | 1 + .../SubmissionDefinitionRestRepository.java | 6 +- .../SubmissionPanelRestRepository.java | 6 +- .../WorkflowItemRestRepository.java | 5 +- .../WorkspaceItemRestRepository.java | 5 +- .../app/rest/submit/SubmissionService.java | 4 +- .../app/rest/CollectionRestRepositoryIT.java | 136 ++++++++++++++---- dspace/config/spring/api/discovery.xml | 14 ++ 17 files changed, 191 insertions(+), 55 deletions(-) diff --git a/dspace-api/src/main/java/org/dspace/app/util/SubmissionConfigReader.java b/dspace-api/src/main/java/org/dspace/app/util/SubmissionConfigReader.java index 0f144fd69f46..4f2c089f9b95 100644 --- a/dspace-api/src/main/java/org/dspace/app/util/SubmissionConfigReader.java +++ b/dspace-api/src/main/java/org/dspace/app/util/SubmissionConfigReader.java @@ -77,11 +77,21 @@ public class SubmissionConfigReader { private static Logger log = org.apache.logging.log4j.LogManager.getLogger(SubmissionConfigReader.class); /** - * The fully qualified pathname of the directory containing the Item Submission Configuration file + * The fully qualified pathname of the directory containing the Item Submission Configuration file. + * Initialized lazily to avoid calling DSpaceServicesFactory.getInstance() before the DSpace kernel + * service manager has fully started (which would cause a NullPointerException when this class is + * instantiated during the kernel's own Spring context initialization). */ - private String configDir = DSpaceServicesFactory.getInstance() - .getConfigurationService().getProperty("dspace.dir") - + File.separator + "config" + File.separator; + private String configDir = null; + + private String getConfigDir() { + if (configDir == null) { + configDir = DSpaceServicesFactory.getInstance() + .getConfigurationService().getProperty("dspace.dir") + + File.separator + "config" + File.separator; + } + return configDir; + } /** * Hashmap which stores which submission process configuration is used by @@ -122,14 +132,14 @@ public class SubmissionConfigReader { * @throws SubmissionConfigReaderException if servlet error */ public SubmissionConfigReader() throws SubmissionConfigReaderException { - buildInputs(configDir + SUBMIT_DEF_FILE_PREFIX + SUBMIT_DEF_FILE_SUFFIX); + buildInputs(getConfigDir() + SUBMIT_DEF_FILE_PREFIX + SUBMIT_DEF_FILE_SUFFIX); } public void reload() throws SubmissionConfigReaderException { collectionToSubmissionConfig = null; stepDefns = null; submitDefns = null; - buildInputs(configDir + SUBMIT_DEF_FILE_PREFIX + SUBMIT_DEF_FILE_SUFFIX); + buildInputs(getConfigDir() + SUBMIT_DEF_FILE_PREFIX + SUBMIT_DEF_FILE_SUFFIX); } /** diff --git a/dspace-api/src/main/java/org/dspace/content/CollectionServiceImpl.java b/dspace-api/src/main/java/org/dspace/content/CollectionServiceImpl.java index d36ddffddc91..b0b64361e02e 100644 --- a/dspace-api/src/main/java/org/dspace/content/CollectionServiceImpl.java +++ b/dspace-api/src/main/java/org/dspace/content/CollectionServiceImpl.java @@ -1020,10 +1020,13 @@ private DiscoverResult retrieveCollectionsWithSubmit(Context context, DiscoverQu discoverQuery.addFilterQueries("search.entitytype:" + entityType); } if (StringUtils.isNotBlank(q)) { - StringBuilder buildQuery = new StringBuilder(); - String escapedQuery = ClientUtils.escapeQueryChars(q); - buildQuery.append("(").append(escapedQuery).append(" OR ").append(escapedQuery).append("*").append(")"); - discoverQuery.setQuery(buildQuery.toString()); + // Build a title prefix filter using dc.title_sort (lowerCaseSort type). + // This field stores the entire title as a single lowercased token, enabling + // reliable case-insensitive prefix matching without analysis/stemming interference. + // The whole query (lowercased) is used as a prefix – e.g. query "te" matches any + // title that starts with "te" (case-insensitive). + String lowerQ = ClientUtils.escapeQueryChars(q.trim().toLowerCase()); + discoverQuery.addFilterQueries("dc.title_sort:" + lowerQ + "*"); } DiscoverResult resp = searchService.search(context, discoverQuery); return resp; diff --git a/dspace-api/src/main/java/org/dspace/discovery/SearchService.java b/dspace-api/src/main/java/org/dspace/discovery/SearchService.java index cb945648e7cd..ab7beb329a08 100644 --- a/dspace-api/src/main/java/org/dspace/discovery/SearchService.java +++ b/dspace-api/src/main/java/org/dspace/discovery/SearchService.java @@ -76,7 +76,7 @@ List search(Context context, String query, String orderfield, b * * @param context The relevant DSpace Context. * @param field the field of the filter query - * @param operator equals/notequals/notcontains/authority/notauthority + * @param operator equals/notequals/notcontains/authority/notauthority/startsWith * @param value the filter query value * @param config (nullable) the discovery configuration (if not null, field's corresponding facet.type checked to * be standard so suffix is not added for equals operator) diff --git a/dspace-api/src/main/java/org/dspace/discovery/SolrServiceImpl.java b/dspace-api/src/main/java/org/dspace/discovery/SolrServiceImpl.java index 4930b9cee165..f81dab70335a 100644 --- a/dspace-api/src/main/java/org/dspace/discovery/SolrServiceImpl.java +++ b/dspace-api/src/main/java/org/dspace/discovery/SolrServiceImpl.java @@ -1252,7 +1252,11 @@ public DiscoverFilterQuery toFilterQuery(Context context, String field, String o filterQuery.append(field); - if (operator.endsWith("equals")) { + if ("startsWith".equals(operator)) { + // Use _sort field: lowerCaseSort type stores whole value as a single lowercased token, + // enabling case-insensitive prefix matching with wildcards. + filterQuery.append("_sort"); + } else if (operator.endsWith("equals")) { final boolean isStandardField = Optional.ofNullable(config) .flatMap(c -> Optional.ofNullable(c.getSidebarFacet(field))) @@ -1272,7 +1276,12 @@ public DiscoverFilterQuery toFilterQuery(Context context, String field, String o filterQuery.append(":"); - if ("equals".equals(operator) || "notequals".equals(operator)) { + if ("startsWith".equals(operator)) { + // Lowercase and escape the value, then append wildcard for prefix matching. + // The _sort field uses lowerCaseSort type, so both indexed and query values are lowercased. + value = ClientUtils.escapeQueryChars(value.toLowerCase()); + filterQuery.append(value).append("*"); + } else if ("equals".equals(operator) || "notequals".equals(operator)) { //DO NOT ESCAPE RANGE QUERIES ! if (!value.matches("\\[.*TO.*\\]")) { value = ClientUtils.escapeQueryChars(value); diff --git a/dspace-api/src/main/java/org/dspace/discovery/indexobject/CollectionIndexFactoryImpl.java b/dspace-api/src/main/java/org/dspace/discovery/indexobject/CollectionIndexFactoryImpl.java index 817be7848df7..c5537ef0901a 100644 --- a/dspace-api/src/main/java/org/dspace/discovery/indexobject/CollectionIndexFactoryImpl.java +++ b/dspace-api/src/main/java/org/dspace/discovery/indexobject/CollectionIndexFactoryImpl.java @@ -128,6 +128,9 @@ public SolrInputDocument buildDocument(Context context, IndexableCollection inde rights_license); addContainerMetadataField(doc, highlightedMetadataFields, toIgnoreMetadataFields, "dc.title", title); doc.addField("dc.title_sort", title); + // Also index as "title_sort" so the standard searchFilterTitle-based + // discovery filter (f.title + startsWith) works for collections. + doc.addField("title_sort", title); if (StringUtils.isBlank(entityType)) { entityType = Constants.ENTITY_TYPE_NONE; diff --git a/dspace-api/src/main/java/org/dspace/discovery/indexobject/CommunityIndexFactoryImpl.java b/dspace-api/src/main/java/org/dspace/discovery/indexobject/CommunityIndexFactoryImpl.java index e92819601839..149f368f17cb 100644 --- a/dspace-api/src/main/java/org/dspace/discovery/indexobject/CommunityIndexFactoryImpl.java +++ b/dspace-api/src/main/java/org/dspace/discovery/indexobject/CommunityIndexFactoryImpl.java @@ -102,6 +102,9 @@ public SolrInputDocument buildDocument(Context context, IndexableCommunity index addContainerMetadataField(doc, highlightedMetadataFields, toIgnoreMetadataFields, "dc.rights", rights); addContainerMetadataField(doc, highlightedMetadataFields, toIgnoreMetadataFields, "dc.title", title); doc.addField("dc.title_sort", title); + // Also index as "title_sort" so the standard searchFilterTitle-based + // discovery filter (f.title + startsWith) works for communities. + doc.addField("title_sort", title); return doc; } diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/converter/AInprogressItemConverter.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/converter/AInprogressItemConverter.java index a5431d90004f..e95876902e48 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/converter/AInprogressItemConverter.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/converter/AInprogressItemConverter.java @@ -25,6 +25,8 @@ import org.dspace.content.InProgressSubmission; import org.dspace.content.Item; import org.dspace.eperson.EPerson; +import javax.annotation.PostConstruct; + import org.dspace.submit.factory.SubmissionServiceFactory; import org.dspace.submit.service.SubmissionConfigService; import org.springframework.beans.factory.annotation.Autowired; @@ -54,13 +56,12 @@ public abstract class AInprogressItemConverter { - public WorkflowItemConverter() throws SubmissionConfigReaderException { + public WorkflowItemConverter() { super(); } diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/converter/WorkspaceItemConverter.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/converter/WorkspaceItemConverter.java index 388ef7e032da..4b8fa8f90cbc 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/converter/WorkspaceItemConverter.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/converter/WorkspaceItemConverter.java @@ -9,7 +9,6 @@ import org.dspace.app.rest.model.WorkspaceItemRest; import org.dspace.app.rest.projection.Projection; -import org.dspace.app.util.SubmissionConfigReaderException; import org.dspace.content.WorkspaceItem; import org.dspace.discovery.IndexableObject; import org.springframework.stereotype.Component; @@ -24,7 +23,7 @@ public class WorkspaceItemConverter extends AInprogressItemConverter { - public WorkspaceItemConverter() throws SubmissionConfigReaderException { + public WorkspaceItemConverter() { super(); } diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/query/RestSearchOperator.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/query/RestSearchOperator.java index ae8713bc69e2..8be34b346fd4 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/query/RestSearchOperator.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/query/RestSearchOperator.java @@ -111,6 +111,7 @@ public static List getListOfAllowedSearchOperatorStrings() { allowedSearchOperatorStrings.add(restSearchOperator.getDspaceOperator()); } allowedSearchOperatorStrings.add("query"); + allowedSearchOperatorStrings.add("startsWith"); return allowedSearchOperatorStrings; } } diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/SubmissionDefinitionRestRepository.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/SubmissionDefinitionRestRepository.java index d964994928eb..44cb524d62e0 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/SubmissionDefinitionRestRepository.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/SubmissionDefinitionRestRepository.java @@ -22,6 +22,7 @@ import org.dspace.core.Context; import org.dspace.submit.factory.SubmissionServiceFactory; import org.dspace.submit.service.SubmissionConfigService; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.security.access.prepost.PreAuthorize; @@ -38,8 +39,9 @@ public class SubmissionDefinitionRestRepository extends DSpaceRestRepository + *
  • "te" must find collections whose title contains a word starting with "te".
  • + *
  • "es" must NOT find "Test collection" because no word starts with "es".
  • + * + */ + @Test + public void findSubmitAuthorizedWithQueryPrefixMatchTest() throws Exception { + context.turnOffAuthorisationSystem(); + + parentCommunity = CommunityBuilder.createCommunity(context) + .withName("Parent Community") + .build(); + + Collection col1 = CollectionBuilder.createCollection(context, parentCommunity) + .withName("Test collection") + .withSubmitterGroup(eperson) + .build(); + Collection col2 = CollectionBuilder.createCollection(context, parentCommunity) + .withName("Testing autocomplete in submission") + .withSubmitterGroup(eperson) + .build(); + Collection col3 = CollectionBuilder.createCollection(context, parentCommunity) + .withName("Sample items repository") + .withSubmitterGroup(eperson) + .build(); + + context.restoreAuthSystemState(); + + String tokenEPerson = getAuthToken(eperson.getEmail(), password); + + // "te" is a prefix of "test" and "testing" → finds col1 and col2 + getClient(tokenEPerson).perform(get("/api/core/collections/search/findSubmitAuthorized") + .param("query", "te")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$._embedded.collections", Matchers.containsInAnyOrder( + CollectionMatcher.matchProperties(col1.getName(), col1.getID(), col1.getHandle()), + CollectionMatcher.matchProperties(col2.getName(), col2.getID(), col2.getHandle()) + ))) + .andExpect(jsonPath("$.page.totalElements", is(2))); + + // "es" is NOT a prefix of any word in "Test collection" → no results + getClient(tokenEPerson).perform(get("/api/core/collections/search/findSubmitAuthorized") + .param("query", "es")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.page.totalElements", is(0))); + + // "samp" is a prefix of "sample" → finds col3 only + getClient(tokenEPerson).perform(get("/api/core/collections/search/findSubmitAuthorized") + .param("query", "samp")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$._embedded.collections", Matchers.contains( + CollectionMatcher.matchProperties(col3.getName(), col3.getID(), col3.getHandle()) + ))) + .andExpect(jsonPath("$.page.totalElements", is(1))); + + // Case-insensitive: "TE" behaves the same as "te" → finds col1 and col2 + getClient(tokenEPerson).perform(get("/api/core/collections/search/findSubmitAuthorized") + .param("query", "TE")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.page.totalElements", is(2))); + + // "test" is a prefix of both "test" (col1) and "testing" (col2) → finds both + getClient(tokenEPerson).perform(get("/api/core/collections/search/findSubmitAuthorized") + .param("query", "test")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$._embedded.collections", Matchers.containsInAnyOrder( + CollectionMatcher.matchProperties(col1.getName(), col1.getID(), col1.getHandle()), + CollectionMatcher.matchProperties(col2.getName(), col2.getID(), col2.getHandle()) + ))) + .andExpect(jsonPath("$.page.totalElements", is(2))); + } + @Test public void findAuthorizedByCommunityWithQueryTest() throws Exception { @@ -802,20 +889,21 @@ public void findAuthorizedByCommunityWithQueryTest() throws Exception { context.restoreAuthSystemState(); String tokenAdminParentCom = getAuthToken(eperson.getEmail(), password); + // "samp" is a prefix of "Sample collection" only (not "Collection of sample items") getClient(tokenAdminParentCom).perform(get("/api/core/collections/search/findSubmitAuthorizedByCommunity") .param("uuid", parentCommunity.getID().toString()) - .param("query", "sample")) + .param("query", "samp")) .andExpect(status().isOk()) .andExpect(content().contentType(contentType)) - .andExpect(jsonPath("$._embedded.collections", Matchers.containsInAnyOrder( - CollectionMatcher.matchProperties(col1.getName(), col1.getID(), col1.getHandle()), - CollectionMatcher.matchProperties(col3.getName(), col3.getID(), col3.getHandle()) + .andExpect(jsonPath("$._embedded.collections", Matchers.contains( + CollectionMatcher.matchProperties(col1.getName(), col1.getID(), col1.getHandle()) ))) - .andExpect(jsonPath("$.page.totalElements", is(2))); + .andExpect(jsonPath("$.page.totalElements", is(1))); + // "collection" is a prefix of "Collection of sample items" (col3) within child2 getClient(tokenAdminParentCom).perform(get("/api/core/collections/search/findSubmitAuthorizedByCommunity") .param("uuid", child2.getID().toString()) - .param("query", "sample")) + .param("query", "collection")) .andExpect(status().isOk()) .andExpect(content().contentType(contentType)) .andExpect(jsonPath("$._embedded.collections", Matchers.contains( diff --git a/dspace/config/spring/api/discovery.xml b/dspace/config/spring/api/discovery.xml index fb25f11598fa..15b10385015c 100644 --- a/dspace/config/spring/api/discovery.xml +++ b/dspace/config/spring/api/discovery.xml @@ -175,6 +175,7 @@ + @@ -2234,6 +2235,19 @@ + + + + + + dc.title + + + + + +