|
| 1 | +/** |
| 2 | + * The contents of this file are subject to the license and copyright |
| 3 | + * detailed in the LICENSE and NOTICE files at the root of the source |
| 4 | + * tree and available online at |
| 5 | + * |
| 6 | + * http://www.dspace.org/license/ |
| 7 | + */ |
| 8 | +package org.dspace.discovery; |
| 9 | + |
| 10 | +import static org.apache.commons.lang3.StringUtils.isNotBlank; |
| 11 | +import static org.dspace.discovery.SearchUtils.AUTHORITY_SEPARATOR; |
| 12 | +import static org.dspace.discovery.SearchUtils.FILTER_SEPARATOR; |
| 13 | +import static org.dspace.discovery.SolrServiceImpl.SOLR_FIELD_SUFFIX_FACET_PREFIXES; |
| 14 | + |
| 15 | +import java.sql.SQLException; |
| 16 | +import java.util.List; |
| 17 | +import java.util.Locale; |
| 18 | +import java.util.stream.Collectors; |
| 19 | + |
| 20 | +import org.apache.commons.lang3.StringUtils; |
| 21 | +import org.apache.solr.common.SolrInputDocument; |
| 22 | +import org.dspace.content.Collection; |
| 23 | +import org.dspace.content.Item; |
| 24 | +import org.dspace.content.MetadataValue; |
| 25 | +import org.dspace.content.authority.ChoiceAuthority; |
| 26 | +import org.dspace.content.authority.DCInputAuthority; |
| 27 | +import org.dspace.content.authority.DSpaceControlledVocabulary; |
| 28 | +import org.dspace.content.authority.service.ChoiceAuthorityService; |
| 29 | +import org.dspace.content.service.ItemService; |
| 30 | +import org.dspace.core.Constants; |
| 31 | +import org.dspace.core.Context; |
| 32 | +import org.dspace.core.I18nUtil; |
| 33 | +import org.dspace.core.exception.SQLRuntimeException; |
| 34 | +import org.dspace.discovery.configuration.DiscoveryConfiguration; |
| 35 | +import org.dspace.discovery.configuration.DiscoverySearchFilter; |
| 36 | +import org.dspace.discovery.configuration.MultiLanguageDiscoverSearchFilterFacet; |
| 37 | +import org.dspace.discovery.indexobject.IndexableItem; |
| 38 | +import org.dspace.services.ConfigurationService; |
| 39 | +import org.dspace.web.ContextUtil; |
| 40 | +import org.slf4j.Logger; |
| 41 | +import org.slf4j.LoggerFactory; |
| 42 | +import org.springframework.beans.factory.annotation.Autowired; |
| 43 | + |
| 44 | +/** |
| 45 | + * Implementation of {@link SolrServiceIndexPlugin} that indexes controlled vocabulary |
| 46 | + * (value pairs) metadata fields into Solr for faceted search support. |
| 47 | + * |
| 48 | + * <p>This plugin processes metadata fields that have a Choice Authority configured (e.g., |
| 49 | + * controlled vocabularies, authority-controlled fields) and adds multiple Solr fields |
| 50 | + * to enable flexible searching and filtering: |
| 51 | + * <ul> |
| 52 | + * <li>{@code <language>_<field>_keyword} - The value with authority suffix for keyword search</li> |
| 53 | + * <li>{@code <language>_<field>_acid} - Lowercase value with separator for acid search</li> |
| 54 | + * <li>{@code <language>_<field>_filter} - Lowercase value for filter queries</li> |
| 55 | + * <li>{@code <language>_<field>_facet} - Value with separator for faceting</li> |
| 56 | + * <li>{@code <language>_<field>_ac} - Autocomplete field</li> |
| 57 | + * <li>{@code <language>_<field>_authority} - Authority key when present</li> |
| 58 | + * </ul> |
| 59 | + * |
| 60 | + * <p>The plugin supports multilingual indexing by iterating through all configured locales |
| 61 | + * and creating language-specific field variants. It handles both {@link DSpaceControlledVocabulary} |
| 62 | + * and {@link DCInputAuthority} authority implementations. |
| 63 | + * |
| 64 | + * <p>Configuration: The separator character used in field values can be customized via |
| 65 | + * the {@code discovery.solr.facets.split.char} configuration property (defaults to |
| 66 | + * {@link SearchUtils#FILTER_SEPARATOR}). |
| 67 | + * |
| 68 | + * @author Luca Giamminonni (luca.giamminonni at 4science.it) |
| 69 | + * @see SolrServiceIndexPlugin |
| 70 | + * @see DiscoverySearchFilter |
| 71 | + */ |
| 72 | +public class SolrServiceValuePairsIndexPlugin implements SolrServiceIndexPlugin { |
| 73 | + |
| 74 | + private static final Logger LOGGER = LoggerFactory.getLogger(SolrServiceValuePairsIndexPlugin.class); |
| 75 | + |
| 76 | + @Autowired |
| 77 | + private ItemService itemService; |
| 78 | + |
| 79 | + @Autowired |
| 80 | + private ChoiceAuthorityService cas; |
| 81 | + |
| 82 | + @Autowired |
| 83 | + private ConfigurationService configurationService; |
| 84 | + |
| 85 | + /** |
| 86 | + * {@inheritDoc} |
| 87 | + * |
| 88 | + * <p>Indexes all controlled vocabulary metadata fields for the given item. |
| 89 | + * Only processes items that have a parent Collection with configured Choice Authorities. |
| 90 | + * For each metadata field with choices configured, adds language-specific Solr fields |
| 91 | + * for faceted search support. |
| 92 | + * |
| 93 | + * @param context the DSpace context |
| 94 | + * @param object the indexable object (must be an {@link IndexableItem}) |
| 95 | + * @param document the Solr document to add fields to |
| 96 | + */ |
| 97 | + @Override |
| 98 | + @SuppressWarnings("rawtypes") |
| 99 | + public void additionalIndex(Context context, IndexableObject object, SolrInputDocument document) { |
| 100 | + if (isNotIndexableItem(object)) { |
| 101 | + return; |
| 102 | + } |
| 103 | + |
| 104 | + Item item = ((IndexableItem)object).getIndexedObject(); |
| 105 | + try { |
| 106 | + Collection collection = (Collection) itemService.getParentObject(context, item); |
| 107 | + for (MetadataValue metadata : item.getItemService().getMetadata(item, Item.ANY, Item.ANY, Item.ANY, |
| 108 | + Item.ANY)) { |
| 109 | + for (Locale locale : I18nUtil.getSupportedLocales()) { |
| 110 | + String language = locale.getLanguage(); |
| 111 | + if (cas.isChoicesConfigured(metadata.getMetadataField().toString(), item.getType(), collection)) { |
| 112 | + additionalIndex(collection, item, metadata, language, document); |
| 113 | + } |
| 114 | + } |
| 115 | + } |
| 116 | + |
| 117 | + } catch (Exception ex) { |
| 118 | + LOGGER.error("An error occurs indexing value pairs for item {}", item.getID(), ex); |
| 119 | + } |
| 120 | + } |
| 121 | + |
| 122 | + /** |
| 123 | + * Adds discovery field values for a specific metadata value in a given language. |
| 124 | + * |
| 125 | + * @param collection the parent collection used to resolve the choice authority |
| 126 | + * @param item the item being indexed |
| 127 | + * @param metadataValue the metadata value to process |
| 128 | + * @param language the language code for the indexed fields |
| 129 | + * @param document the Solr document to add fields to |
| 130 | + */ |
| 131 | + private void additionalIndex(Collection collection, Item item, MetadataValue metadataValue, String language, |
| 132 | + SolrInputDocument document) { |
| 133 | + String metadataField = metadataValue.getMetadataField().toString('.'); |
| 134 | + List<DiscoverySearchFilter> searchFilters = findSearchFiltersByMetadataField(item, metadataField); |
| 135 | + String authority = metadataValue.getAuthority(); |
| 136 | + String value = getMetadataValue(collection, metadataValue, language); |
| 137 | + if (StringUtils.isNotBlank(value)) { |
| 138 | + for (DiscoverySearchFilter searchFilter : searchFilters) { |
| 139 | + addDiscoveryFieldFields(language, document, value, authority, searchFilter); |
| 140 | + } |
| 141 | + } |
| 142 | + } |
| 143 | + |
| 144 | + /** |
| 145 | + * Resolves the display label for a metadata value based on its authority. |
| 146 | + * |
| 147 | + * <p>For controlled vocabularies, returns the authority label if available, |
| 148 | + * otherwise falls back to the raw metadata value. For DCInputAuthority fields, |
| 149 | + * always attempts to resolve the label from the authority. |
| 150 | + * |
| 151 | + * @param collection the parent collection used to resolve the choice authority |
| 152 | + * @param metadataValue the metadata value to resolve |
| 153 | + * @param language the language for label resolution |
| 154 | + * @return the display label, or null if no suitable label can be found |
| 155 | + */ |
| 156 | + private String getMetadataValue(Collection collection, MetadataValue metadataValue, String language) { |
| 157 | + String fieldKey = metadataValue.getMetadataField().toString(); |
| 158 | + ChoiceAuthority choiceAuthority = cas.getAuthorityByFieldKeyCollection(fieldKey, Constants.ITEM, collection); |
| 159 | + String authority = metadataValue.getAuthority(); |
| 160 | + if (choiceAuthority instanceof DSpaceControlledVocabulary) { |
| 161 | + String label = StringUtils.isNotBlank(authority) ? choiceAuthority.getLabel(authority, language) |
| 162 | + : metadataValue.getValue(); |
| 163 | + if (StringUtils.isBlank(label)) { |
| 164 | + label = metadataValue.getValue(); |
| 165 | + } |
| 166 | + return label; |
| 167 | + } else if (choiceAuthority instanceof DCInputAuthority) { |
| 168 | + String label = choiceAuthority.getLabel(metadataValue.getValue(), language); |
| 169 | + if (StringUtils.isBlank(label)) { |
| 170 | + label = metadataValue.getValue(); |
| 171 | + } |
| 172 | + return label; |
| 173 | + } |
| 174 | + return null; |
| 175 | + } |
| 176 | + |
| 177 | + /** |
| 178 | + * Adds multiple Solr fields for a discovery search filter value. |
| 179 | + * |
| 180 | + * <p>Creates the following fields (with language prefix): |
| 181 | + * <ul> |
| 182 | + * <li>{@code _keyword} - Value with optional authority suffix for keyword search</li> |
| 183 | + * <li>{@code _acid} - Lowercase value with separator for acid search</li> |
| 184 | + * <li>{@code _filter} - Lowercase value for filter queries</li> |
| 185 | + * <li>{@code _facet} - Value with separator for faceting (uses {@link SolrServiceImpl#SOLR_FIELD_SUFFIX_FACET_PREFIXES})</li> |
| 186 | + * <li>{@code _ac} - Lowercase value with separator for autocomplete</li> |
| 187 | + * <li>{@code _authority} - The authority key if present and existing authority field exists</li> |
| 188 | + * </ul> |
| 189 | + * |
| 190 | + * @param language the language code to prefix field names with |
| 191 | + * @param document the Solr document to add fields to |
| 192 | + * @param value the display value to index |
| 193 | + * @param authority the authority key (may be null or blank) |
| 194 | + * @param searchFilter the discovery search filter configuration |
| 195 | + */ |
| 196 | + private void addDiscoveryFieldFields(String language, SolrInputDocument document, String value, String authority, |
| 197 | + DiscoverySearchFilter searchFilter) { |
| 198 | + String separator = configurationService.getProperty("discovery.solr.facets.split.char", FILTER_SEPARATOR); |
| 199 | + String fieldNameWithLanguage = language + "_" + searchFilter.getIndexFieldName(); |
| 200 | + String valueLowerCase = value.toLowerCase(); |
| 201 | + |
| 202 | + String keywordField = appendAuthorityIfNotBlank(value, authority); |
| 203 | + String acidField = appendAuthorityIfNotBlank(valueLowerCase + separator + value, authority); |
| 204 | + String filterField = appendAuthorityIfNotBlank(valueLowerCase + separator + value, authority); |
| 205 | + String prefixField = appendAuthorityIfNotBlank(valueLowerCase + separator + value, authority); |
| 206 | + |
| 207 | + document.addField(fieldNameWithLanguage + "_keyword", keywordField); |
| 208 | + document.addField(fieldNameWithLanguage + "_acid", acidField); |
| 209 | + document.addField(fieldNameWithLanguage + "_filter", filterField); |
| 210 | + document.addField(fieldNameWithLanguage + SOLR_FIELD_SUFFIX_FACET_PREFIXES, prefixField); |
| 211 | + document.addField(fieldNameWithLanguage + "_ac", valueLowerCase + separator + value); |
| 212 | + if (document.containsKey(searchFilter.getIndexFieldName() + "_authority")) { |
| 213 | + document.addField(fieldNameWithLanguage + "_authority", authority); |
| 214 | + } |
| 215 | + } |
| 216 | + |
| 217 | + /** |
| 218 | + * Appends the authority separator and authority key to a field value if the authority |
| 219 | + * is not blank. |
| 220 | + * |
| 221 | + * @param fieldValue the base field value |
| 222 | + * @param authority the authority key (may be null or blank) |
| 223 | + * @return the field value with authority appended if present, otherwise just the field value |
| 224 | + */ |
| 225 | + private String appendAuthorityIfNotBlank(String fieldValue, String authority) { |
| 226 | + return isNotBlank(authority) ? fieldValue + AUTHORITY_SEPARATOR + authority : fieldValue; |
| 227 | + } |
| 228 | + |
| 229 | + /** |
| 230 | + * Returns all the search fields configured for the given metadataField. Filters |
| 231 | + * returned are not filtered by instance type equal to |
| 232 | + * {@link MultiLanguageDiscoverSearchFilterFacet} to allow for language-based |
| 233 | + * searches. |
| 234 | + * |
| 235 | + * @param item the item being indexed (used to resolve discovery configuration) |
| 236 | + * @param metadataField the metadata field key to search for (e.g., "dc.subject") |
| 237 | + * @return list of discovery search filters that include this metadata field |
| 238 | + */ |
| 239 | + private List<DiscoverySearchFilter> findSearchFiltersByMetadataField(Item item, String metadataField) { |
| 240 | + return getAllDiscoveryConfiguration(item).stream() |
| 241 | + .flatMap(discoveryConfiguration -> discoveryConfiguration.getSearchFilters().stream()) |
| 242 | + .filter(searchFilter -> searchFilter.getMetadataFields().contains(metadataField)) |
| 243 | + .distinct() |
| 244 | + .collect(Collectors.toList()); |
| 245 | + } |
| 246 | + |
| 247 | + /** |
| 248 | + * Retrieves all discovery configurations associated with an item. |
| 249 | + * |
| 250 | + * @param item the item to get configurations for |
| 251 | + * @return list of discovery configurations for the item's collections |
| 252 | + * @throws SQLRuntimeException if a database error occurs |
| 253 | + */ |
| 254 | + private List<DiscoveryConfiguration> getAllDiscoveryConfiguration(Item item) { |
| 255 | + try { |
| 256 | + return SearchUtils.getAllDiscoveryConfigurations(ContextUtil.obtainCurrentRequestContext(), item); |
| 257 | + } catch (SQLException e) { |
| 258 | + throw new SQLRuntimeException(e); |
| 259 | + } |
| 260 | + } |
| 261 | + |
| 262 | + /** |
| 263 | + * Checks whether the given indexable object is not an indexable item. |
| 264 | + * |
| 265 | + * @param object the indexable object to check |
| 266 | + * @return true if the object is not an IndexableItem instance |
| 267 | + */ |
| 268 | + @SuppressWarnings("rawtypes") |
| 269 | + private boolean isNotIndexableItem(IndexableObject object) { |
| 270 | + return !(object instanceof IndexableItem); |
| 271 | + } |
| 272 | + |
| 273 | +} |
0 commit comments