diff --git a/pom.xml b/pom.xml
index 0e7465286..f4cc7bd58 100644
--- a/pom.xml
+++ b/pom.xml
@@ -51,7 +51,6 @@
0.8.2
- 1.19.4
2.27.2
4.2.0
@@ -256,34 +255,6 @@
-
- org.testcontainers
- testcontainers
- ${testcontainers.version}
- test
-
-
-
- org.testcontainers
- junit-jupiter
- ${testcontainers.version}
- test
-
-
-
- org.testcontainers
- kafka
- ${testcontainers.version}
- test
-
-
-
- org.testcontainers
- postgresql
- ${testcontainers.version}
- test
-
-
org.awaitility
awaitility
@@ -300,9 +271,8 @@
org.folio
- folio-service-tools-spring-test
- ${folio-service-tools.version}
- test
+ folio-spring-testing
+ ${folio-spring-support.version}
diff --git a/src/main/java/org/folio/search/repository/classification/InstanceClassificationEntity.java b/src/main/java/org/folio/search/repository/classification/InstanceClassificationEntity.java
new file mode 100644
index 000000000..ed88bc5f0
--- /dev/null
+++ b/src/main/java/org/folio/search/repository/classification/InstanceClassificationEntity.java
@@ -0,0 +1,59 @@
+package org.folio.search.repository.classification;
+
+import java.util.Objects;
+import lombok.Builder;
+
+public record InstanceClassificationEntity(
+ Id id,
+ boolean shared
+) {
+
+ public InstanceClassificationEntity {
+ Objects.requireNonNull(id);
+ }
+
+ public String type() {
+ return id().type();
+ }
+
+ public String number() {
+ return id().number();
+ }
+
+ public String instanceId() {
+ return id().instanceId();
+ }
+
+ public String tenantId() {
+ return id().tenantId();
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ InstanceClassificationEntity that = (InstanceClassificationEntity) o;
+ return Objects.equals(id, that.id);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(id);
+ }
+
+ @Builder
+ public record Id(String type,
+ String number,
+ String instanceId,
+ String tenantId) {
+ public Id {
+ Objects.requireNonNull(number);
+ Objects.requireNonNull(instanceId);
+ Objects.requireNonNull(tenantId);
+ }
+ }
+}
diff --git a/src/main/java/org/folio/search/repository/classification/InstanceClassificationEntityAgg.java b/src/main/java/org/folio/search/repository/classification/InstanceClassificationEntityAgg.java
new file mode 100644
index 000000000..a76af3a0a
--- /dev/null
+++ b/src/main/java/org/folio/search/repository/classification/InstanceClassificationEntityAgg.java
@@ -0,0 +1,12 @@
+package org.folio.search.repository.classification;
+
+import java.util.List;
+import org.folio.search.model.index.InstanceSubResource;
+
+public record InstanceClassificationEntityAgg(
+ String type,
+ String number,
+ List instances
+) {
+
+}
diff --git a/src/main/java/org/folio/search/repository/classification/InstanceClassificationJdbcRepository.java b/src/main/java/org/folio/search/repository/classification/InstanceClassificationJdbcRepository.java
new file mode 100644
index 000000000..2699c715a
--- /dev/null
+++ b/src/main/java/org/folio/search/repository/classification/InstanceClassificationJdbcRepository.java
@@ -0,0 +1,187 @@
+package org.folio.search.repository.classification;
+
+import static org.folio.search.utils.JdbcUtils.getQuestionMarkPlaceholder;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.sql.PreparedStatement;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.log4j.Log4j2;
+import org.folio.search.model.index.InstanceSubResource;
+import org.folio.search.utils.JdbcUtils;
+import org.folio.spring.FolioExecutionContext;
+import org.jetbrains.annotations.NotNull;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.core.RowMapper;
+import org.springframework.stereotype.Repository;
+
+@Log4j2
+@Repository
+@RequiredArgsConstructor
+public class InstanceClassificationJdbcRepository implements InstanceClassificationRepository {
+
+ private static final String INSTANCE_CLASSIFICATION_TABLE_NAME = "instance_classification";
+ private static final String CLASSIFICATION_TYPE_COLUMN = "classification_type";
+ private static final String CLASSIFICATION_NUMBER_COLUMN = "classification_number";
+ private static final String TENANT_ID_COLUMN = "tenant_id";
+ private static final String INSTANCE_ID_COLUMN = "instance_id";
+ private static final String SHARED_COLUMN = "shared";
+ private static final String CLASSIFICATION_TYPE_DEFAULT = "";
+
+ private static final String SELECT_ALL_SQL = "SELECT * FROM %s;";
+ private static final String SELECT_ALL_BY_INSTANCE_ID_AGG = """
+ SELECT
+ t1.classification_number,
+ t1.classification_type,
+ json_agg(json_build_object(
+ 'instanceId', t1.instance_id,
+ 'shared', t1.shared,
+ 'tenantId', t1.tenant_id
+ )) AS instances
+ FROM %1$s t1
+ INNER JOIN %1$s t2 ON t1.classification_number = t2.classification_number
+ AND t1.classification_type = t2.classification_type
+ AND t2.instance_id IN (%2$s)
+ GROUP BY t1.classification_number, t1.classification_type;
+ """;
+ private static final String INSERT_SQL;
+ private static final String DELETE_SQL;
+ private static final int BATCH_SIZE = 100;
+ private static final TypeReference> VALUE_TYPE_REF = new TypeReference<>() { };
+
+ static {
+ var allColumns = new ArrayList<>(Arrays.asList(
+ CLASSIFICATION_TYPE_COLUMN,
+ CLASSIFICATION_NUMBER_COLUMN,
+ TENANT_ID_COLUMN,
+ INSTANCE_ID_COLUMN,
+ SHARED_COLUMN
+ ));
+
+ var pkColumns = new ArrayList<>(Arrays.asList(
+ CLASSIFICATION_TYPE_COLUMN,
+ CLASSIFICATION_NUMBER_COLUMN,
+ TENANT_ID_COLUMN,
+ INSTANCE_ID_COLUMN
+ ));
+
+ INSERT_SQL = "INSERT INTO %s (" + String.join(",", allColumns) + ") "
+ + "VALUES (?, ?, ?, ?, ?) "
+ + "ON CONFLICT (" + String.join(",", pkColumns) + ") "
+ + "DO UPDATE SET " + SHARED_COLUMN + " = EXCLUDED." + SHARED_COLUMN + ";";
+
+ DELETE_SQL = "DELETE FROM %s "
+ + "WHERE " + CLASSIFICATION_TYPE_COLUMN + " = ? AND "
+ + CLASSIFICATION_NUMBER_COLUMN + " = ? AND "
+ + TENANT_ID_COLUMN + " = ? AND "
+ + INSTANCE_ID_COLUMN + " = ?;";
+
+ }
+
+ private final FolioExecutionContext context;
+ private final JdbcTemplate jdbcTemplate;
+ private final ObjectMapper objectMapper;
+
+ public void saveAll(List classifications) {
+ log.debug("saveAll::instance classifications [entities: {}]", classifications);
+
+ if (classifications == null || classifications.isEmpty()) {
+ return;
+ }
+
+ var uniqueEntities = classifications.stream().distinct().toList();
+
+ jdbcTemplate.batchUpdate(
+ INSERT_SQL.formatted(getTableName()),
+ uniqueEntities,
+ BATCH_SIZE,
+ (PreparedStatement ps, InstanceClassificationEntity item) -> {
+ var id = item.id();
+ ps.setString(1, itemTypeToDatabaseValue(id));
+ ps.setString(2, id.number());
+ ps.setString(3, id.tenantId());
+ ps.setString(4, id.instanceId());
+ ps.setBoolean(5, item.shared());
+ });
+ }
+
+ @Override
+ public void deleteAll(List classifications) {
+ log.debug("deleteAll::instance classifications [entities: {}]", classifications);
+
+ if (classifications == null || classifications.isEmpty()) {
+ return;
+ }
+
+ jdbcTemplate.batchUpdate(
+ DELETE_SQL.formatted(getTableName()),
+ classifications,
+ BATCH_SIZE,
+ (PreparedStatement ps, InstanceClassificationEntity item) -> {
+ var id = item.id();
+ ps.setString(1, itemTypeToDatabaseValue(id));
+ ps.setString(2, id.number());
+ ps.setString(3, id.tenantId());
+ ps.setString(4, id.instanceId());
+ });
+
+ }
+
+ @Override
+ public List findAll() {
+ log.debug("findAll::instance classifications");
+ return jdbcTemplate.query(SELECT_ALL_SQL.formatted(getTableName()), instanceClassificationRowMapper());
+ }
+
+ @Override
+ public List findAllByInstanceIds(List instanceIds) {
+ log.debug("findAllByInstanceIds::instance classifications [instanceIds: {}]", instanceIds);
+ return jdbcTemplate.query(
+ SELECT_ALL_BY_INSTANCE_ID_AGG.formatted(getTableName(), getQuestionMarkPlaceholder(instanceIds.size())),
+ instanceClassificationAggRowMapper(),
+ instanceIds.toArray());
+ }
+
+ @NotNull
+ private RowMapper instanceClassificationRowMapper() {
+ return (rs, rowNum) -> {
+ var builder = InstanceClassificationEntity.Id.builder();
+ var typeVal = rs.getString(CLASSIFICATION_TYPE_COLUMN);
+ builder.type(CLASSIFICATION_TYPE_DEFAULT.equals(typeVal) ? null : typeVal);
+ builder.number(rs.getString(CLASSIFICATION_NUMBER_COLUMN));
+ builder.instanceId(rs.getString(INSTANCE_ID_COLUMN));
+ builder.tenantId(rs.getString(TENANT_ID_COLUMN));
+ var shared = rs.getBoolean(SHARED_COLUMN);
+ return new InstanceClassificationEntity(builder.build(), shared);
+ };
+ }
+
+ @NotNull
+ private RowMapper instanceClassificationAggRowMapper() {
+ return (rs, rowNum) -> {
+ var typeVal = rs.getString(CLASSIFICATION_TYPE_COLUMN);
+ var type = CLASSIFICATION_TYPE_DEFAULT.equals(typeVal) ? null : typeVal;
+ var number = rs.getString(CLASSIFICATION_NUMBER_COLUMN);
+ var instancesJson = rs.getString("instances");
+ List instanceSubResources;
+ try {
+ instanceSubResources = objectMapper.readValue(instancesJson, VALUE_TYPE_REF);
+ } catch (JsonProcessingException e) {
+ throw new IllegalArgumentException(e);
+ }
+ return new InstanceClassificationEntityAgg(type, number, instanceSubResources);
+ };
+ }
+
+ private String getTableName() {
+ return JdbcUtils.getFullTableName(context, INSTANCE_CLASSIFICATION_TABLE_NAME);
+ }
+
+ private String itemTypeToDatabaseValue(InstanceClassificationEntity.Id id) {
+ return id.type() == null ? CLASSIFICATION_TYPE_DEFAULT : id.type();
+ }
+}
diff --git a/src/main/java/org/folio/search/repository/classification/InstanceClassificationRepository.java b/src/main/java/org/folio/search/repository/classification/InstanceClassificationRepository.java
new file mode 100644
index 000000000..113880081
--- /dev/null
+++ b/src/main/java/org/folio/search/repository/classification/InstanceClassificationRepository.java
@@ -0,0 +1,14 @@
+package org.folio.search.repository.classification;
+
+import java.util.List;
+
+public interface InstanceClassificationRepository {
+
+ void saveAll(List classifications);
+
+ void deleteAll(List classifications);
+
+ List findAll();
+
+ List findAllByInstanceIds(List instanceId);
+}
diff --git a/src/main/java/org/folio/search/service/consortium/ConsortiumInstanceRepository.java b/src/main/java/org/folio/search/service/consortium/ConsortiumInstanceRepository.java
index 68b14fe96..7feee85cd 100644
--- a/src/main/java/org/folio/search/service/consortium/ConsortiumInstanceRepository.java
+++ b/src/main/java/org/folio/search/service/consortium/ConsortiumInstanceRepository.java
@@ -1,6 +1,7 @@
package org.folio.search.service.consortium;
-import static java.util.Collections.nCopies;
+import static org.folio.search.utils.JdbcUtils.getFullTableName;
+import static org.folio.search.utils.JdbcUtils.getQuestionMarkPlaceholder;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
@@ -21,7 +22,7 @@
@RequiredArgsConstructor
public class ConsortiumInstanceRepository {
- private static final String CONSORTIUM_INSTANCE_TABLE_NAME = "consortium_instance";
+ static final String CONSORTIUM_INSTANCE_TABLE_NAME = "consortium_instance";
private static final String SELECT_BY_ID_SQL = "SELECT * FROM %s WHERE instance_id IN (%s)";
private static final String DELETE_BY_TENANT_AND_ID_SQL = "DELETE FROM %s WHERE tenant_id = ? AND instance_id = ?;";
private static final String UPSERT_SQL = """
@@ -40,7 +41,7 @@ ON CONFLICT (tenant_id, instance_id)
public List fetch(List instanceIds) {
log.debug("fetch::consortium instances by [ids: {}]", instanceIds);
return jdbcTemplate.query(
- SELECT_BY_ID_SQL.formatted(getFullTableName(), String.join(",", nCopies(instanceIds.size(), "?"))),
+ SELECT_BY_ID_SQL.formatted(getTableName(), getQuestionMarkPlaceholder(instanceIds.size())),
(rs, rowNum) -> toConsortiumInstance(rs),
instanceIds.toArray());
}
@@ -48,7 +49,7 @@ public List fetch(List instanceIds) {
public void save(List instances) {
log.debug("save::consortium instances [number: {}]", instances.size());
jdbcTemplate.batchUpdate(
- UPSERT_SQL.formatted(getFullTableName()),
+ UPSERT_SQL.formatted(getTableName()),
instances,
100,
(PreparedStatement ps, ConsortiumInstance item) -> {
@@ -63,7 +64,7 @@ public void save(List instances) {
public void delete(Set instanceIds) {
log.debug("delete::consortium instances [tenant-instanceIds: {}]", instanceIds);
jdbcTemplate.batchUpdate(
- DELETE_BY_TENANT_AND_ID_SQL.formatted(getFullTableName()),
+ DELETE_BY_TENANT_AND_ID_SQL.formatted(getTableName()),
instanceIds,
100,
(PreparedStatement ps, ConsortiumInstanceId id) -> {
@@ -78,8 +79,7 @@ private ConsortiumInstance toConsortiumInstance(ResultSet rs) throws SQLExceptio
return new ConsortiumInstance(id, rs.getString(JSON_COLUMN));
}
- private String getFullTableName() {
- var dbSchemaName = context.getFolioModuleMetadata().getDBSchemaName(context.getTenantId());
- return dbSchemaName + "." + CONSORTIUM_INSTANCE_TABLE_NAME;
+ private String getTableName() {
+ return getFullTableName(context, CONSORTIUM_INSTANCE_TABLE_NAME);
}
}
diff --git a/src/main/java/org/folio/search/service/converter/MultiTenantSearchDocumentConverter.java b/src/main/java/org/folio/search/service/converter/MultiTenantSearchDocumentConverter.java
index e3a1b7953..4c227393f 100644
--- a/src/main/java/org/folio/search/service/converter/MultiTenantSearchDocumentConverter.java
+++ b/src/main/java/org/folio/search/service/converter/MultiTenantSearchDocumentConverter.java
@@ -78,7 +78,7 @@ private Stream populateResourceEvents(ResourceEvent event) {
.map(ResourceDescription::getIndexingConfiguration)
.map(ResourceIndexingConfiguration::getEventPreProcessor)
.map(eventPreProcessorBeans::get)
- .map(eventPreProcessor -> eventPreProcessor.process(event))
+ .map(eventPreProcessor -> eventPreProcessor.preProcess(event))
.map(Collection::stream)
.orElseGet(() -> Stream.of(event));
}
diff --git a/src/main/java/org/folio/search/service/converter/preprocessor/AuthorityEventPreProcessor.java b/src/main/java/org/folio/search/service/converter/preprocessor/AuthorityEventPreProcessor.java
index 4037fc796..3c05d9cec 100644
--- a/src/main/java/org/folio/search/service/converter/preprocessor/AuthorityEventPreProcessor.java
+++ b/src/main/java/org/folio/search/service/converter/preprocessor/AuthorityEventPreProcessor.java
@@ -65,7 +65,7 @@ public void init() {
* @return list with divided authority event objects
*/
@Override
- public List process(ResourceEvent event) {
+ public List preProcess(ResourceEvent event) {
log.debug("process:: by [id: {}, tenant: {}, resourceType: {}]",
event.getId(), event.getTenant(), event.getType());
diff --git a/src/main/java/org/folio/search/service/converter/preprocessor/EventPreProcessor.java b/src/main/java/org/folio/search/service/converter/preprocessor/EventPreProcessor.java
index 943b2f3ec..024f462c6 100644
--- a/src/main/java/org/folio/search/service/converter/preprocessor/EventPreProcessor.java
+++ b/src/main/java/org/folio/search/service/converter/preprocessor/EventPreProcessor.java
@@ -13,5 +13,5 @@ public interface EventPreProcessor {
* @return list with resource events, where key is the resource name and value is the {@link List} with generated
* {@link ResourceEvent} objects
*/
- List process(ResourceEvent event);
+ List preProcess(ResourceEvent event);
}
diff --git a/src/main/java/org/folio/search/service/converter/preprocessor/InstanceEventPreProcessor.java b/src/main/java/org/folio/search/service/converter/preprocessor/InstanceEventPreProcessor.java
new file mode 100644
index 000000000..076e178ab
--- /dev/null
+++ b/src/main/java/org/folio/search/service/converter/preprocessor/InstanceEventPreProcessor.java
@@ -0,0 +1,116 @@
+package org.folio.search.service.converter.preprocessor;
+
+import static java.util.Collections.emptyList;
+import static java.util.Collections.emptySet;
+import static org.apache.commons.collections4.MapUtils.getObject;
+import static org.apache.commons.lang3.StringUtils.startsWith;
+import static org.folio.search.utils.CollectionUtils.subtract;
+import static org.folio.search.utils.SearchConverterUtils.getNewAsMap;
+import static org.folio.search.utils.SearchConverterUtils.getOldAsMap;
+import static org.folio.search.utils.SearchConverterUtils.getResourceEventId;
+import static org.folio.search.utils.SearchConverterUtils.getResourceSource;
+import static org.folio.search.utils.SearchUtils.CLASSIFICATIONS_FIELD;
+import static org.folio.search.utils.SearchUtils.CLASSIFICATION_NUMBER_FIELD;
+import static org.folio.search.utils.SearchUtils.CLASSIFICATION_TYPE_FIELD;
+import static org.folio.search.utils.SearchUtils.SOURCE_CONSORTIUM_PREFIX;
+
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.log4j.Log4j2;
+import org.apache.commons.collections4.MapUtils;
+import org.folio.search.domain.dto.ResourceEvent;
+import org.folio.search.domain.dto.TenantConfiguredFeature;
+import org.folio.search.repository.classification.InstanceClassificationEntity;
+import org.folio.search.repository.classification.InstanceClassificationRepository;
+import org.folio.search.service.FeatureConfigService;
+import org.folio.search.service.consortium.ConsortiumTenantService;
+import org.jetbrains.annotations.NotNull;
+import org.springframework.stereotype.Component;
+
+@Log4j2
+@Component
+@RequiredArgsConstructor
+public class InstanceEventPreProcessor implements EventPreProcessor {
+
+ private final FeatureConfigService featureConfigService;
+ private final ConsortiumTenantService consortiumTenantService;
+ private final InstanceClassificationRepository instanceClassificationRepository;
+
+ @Override
+ public List preProcess(ResourceEvent event) {
+ log.info("preProcess::Starting instance event pre-processing");
+ if (log.isDebugEnabled()) {
+ log.debug("preProcess::Starting instance event pre-processing [{}]", event);
+ }
+ if (startsWith(getResourceSource(event), SOURCE_CONSORTIUM_PREFIX)) {
+ log.info("preProcess::Finished instance event pre-processing. No additional events created for shadow instance.");
+ return List.of(event);
+ }
+
+ var events = processClassifications(event);
+
+ log.info("preProcess::Finished instance event pre-processing");
+ if (log.isDebugEnabled()) {
+ log.debug("preProcess::Finished instance event pre-processing. Events after: [{}], ", events);
+ }
+ return events;
+ }
+
+ private List processClassifications(ResourceEvent event) {
+ if (!featureConfigService.isEnabled(TenantConfiguredFeature.BROWSE_CLASSIFICATIONS)) {
+ return List.of(event);
+ }
+
+ var oldClassifications = getClassifications(getOldAsMap(event));
+ var newClassifications = getClassifications(getNewAsMap(event));
+
+ if (oldClassifications.equals(newClassifications)) {
+ return List.of(event);
+ }
+
+ var tenant = event.getTenant();
+ var instanceId = getResourceEventId(event);
+ var shared = isShared(tenant);
+
+ var classificationsForCreate = subtract(newClassifications, oldClassifications);
+ var classificationsForDelete = subtract(oldClassifications, newClassifications);
+
+ instanceClassificationRepository.saveAll(toEntities(classificationsForCreate, instanceId, tenant, shared));
+ instanceClassificationRepository.deleteAll(toEntities(classificationsForDelete, instanceId, tenant, shared));
+
+ return List.of(event);
+ }
+
+ @NotNull
+ private static List toEntities(
+ Set