Introduce soft deleted list option for soft deletable entities (#3093)

* Introduce soft deleted list option for soft deletable entities

Signed-off-by: strailov <Stanislav.Trailov@bosch.io>

* fix verify build

Signed-off-by: strailov <Stanislav.Trailov@bosch.io>

* fix typo in license

Signed-off-by: strailov <Stanislav.Trailov@bosch.io>

* Add sorting option on deleted field

Signed-off-by: strailov <Stanislav.Trailov@bosch.io>

* add missing import in tests

Signed-off-by: strailov <Stanislav.Trailov@bosch.io>

* Rename SoftDeletedFilter to SoftDeletedMode and its values

Signed-off-by: strailov <Stanislav.Trailov@bosch.io>

* Introduce MgmtSoftDeletedMode on api layer

Signed-off-by: strailov <Stanislav.Trailov@bosch.io>

* remove unused imports

Signed-off-by: strailov <Stanislav.Trailov@bosch.io>

* Integrate the enum on API layer

Signed-off-by: strailov <Stanislav.Trailov@bosch.io>

* Fix OpenApi spec

Signed-off-by: strailov <Stanislav.Trailov@bosch.io>

* address some comments

Signed-off-by: strailov <Stanislav.Trailov@bosch.io>

* Get rid of count(SoftDeletedMode) at all

Signed-off-by: strailov <Stanislav.Trailov@bosch.io>

* remove formatter for enum - stop supporting lowercase values in API

Signed-off-by: strailov <Stanislav.Trailov@bosch.io>

---------

Signed-off-by: strailov <Stanislav.Trailov@bosch.io>
This commit is contained in:
Stanislav Trailov
2026-06-08 16:37:05 +03:00
committed by GitHub
parent 95680962cc
commit f44b6268b0
45 changed files with 1230 additions and 101 deletions

View File

@@ -38,6 +38,7 @@ import org.eclipse.hawkbit.ql.jpa.QLSupport;
import org.eclipse.hawkbit.repository.DistributionSetManagement;
import org.eclipse.hawkbit.repository.Identifiable;
import org.eclipse.hawkbit.repository.RepositoryManagement;
import org.eclipse.hawkbit.repository.SoftDeletedMode;
import org.eclipse.hawkbit.repository.exception.EntityNotFoundException;
import org.eclipse.hawkbit.repository.exception.InvalidDistributionSetException;
import org.eclipse.hawkbit.repository.jpa.Jpa;
@@ -382,7 +383,13 @@ abstract class AbstractJpaRepositoryManagement<T extends AbstractJpaBaseEntity,
private final Supplier<Optional<Specification<T>>> isNotDeletedSupplier = SingletonSupplier.of(this::isNotDeleted0);
private Optional<Specification<T>> isNotDeleted() {
protected Optional<Specification<T>> isSoftDeleted() {
return supportSoftDelete()
? Optional.of((root, query, cb) -> cb.equal(root.get(DELETED), true))
: Optional.empty();
}
protected Optional<Specification<T>> isNotDeleted() {
return isNotDeletedSupplier.get();
}

View File

@@ -30,8 +30,10 @@ import org.eclipse.hawkbit.ql.Node;
import org.eclipse.hawkbit.ql.jpa.QLSupport;
import org.eclipse.hawkbit.repository.DistributionSetManagement;
import org.eclipse.hawkbit.repository.DistributionSetTagManagement;
import org.eclipse.hawkbit.repository.Identifiable;
import org.eclipse.hawkbit.repository.QuotaManagement;
import org.eclipse.hawkbit.repository.RepositoryProperties;
import org.eclipse.hawkbit.repository.SoftDeletedMode;
import org.eclipse.hawkbit.repository.exception.DeletedException;
import org.eclipse.hawkbit.repository.exception.EntityNotFoundException;
import org.eclipse.hawkbit.repository.exception.IncompleteDistributionSetException;
@@ -105,46 +107,23 @@ public class JpaDistributionSetManagement
@Override
@SuppressWarnings("java:S3776") // java:S3776 - just too complex
public Page<JpaDistributionSet> findByRsql(final String rsql, final Pageable pageable) {
if (rsql != null && rsql.toLowerCase().contains(COMPLETE)) {
// limited support for 'complete' - could be removed in future
final Node node = QLSupport.getInstance().parse(rsql);
final Specification<JpaDistributionSet> notDeleted = (root, query, cb) -> cb.equal(root.get(DELETED), false);
final List<Specification<JpaDistributionSet>> specList = new ArrayList<>();
specList.add(notDeleted);
final AtomicReference<Node.Comparison> completedComparison = new AtomicReference<>();
if (node instanceof Node.Comparison comparison && COMPLETE.equalsIgnoreCase(comparison.getKey())) {
// all not deleted, won't add anything to spec
completedComparison.set(comparison);
} else if (node instanceof Node.Logical logical && logical.getOp() == Node.Logical.Operator.AND) {
final List<Node> sanitizedChildren = new ArrayList<>();
logical.getChildren().forEach(child -> {
if (child instanceof Node.Comparison comparison && COMPLETE.equalsIgnoreCase(comparison.getKey())) {
if (completedComparison.get() != null) {
throw new RSQLParameterSyntaxException("Multiple 'complete' comparisons are not supported");
}
completedComparison.set(comparison);
} else {
sanitizedChildren.add(child);
}
});
specList.add(QLSupport.getInstance().buildSpec(
sanitizedChildren.size() == 1
? sanitizedChildren.get(0)
: new Node.Logical(Node.Logical.Operator.AND, sanitizedChildren),
DistributionSetFields.class));
}
if (completedComparison.get() != null) { // really a comparison
log.warn("Usage of 'complete' is limited and may be removed: {}", node);
final boolean completed = completeComparison(completedComparison);
return filter(JpaManagementHelper.findAllWithCountBySpec(jpaRepository, specList, pageable), completed);
}
}
return findByRsqlAndDeleted(rsql, SoftDeletedMode.EXCLUDE_SOFT_DELETED, pageable);
}
return super.findByRsql(rsql, pageable);
@Override
public Page<JpaDistributionSet> findByRsql(String rsql, SoftDeletedMode softDeletedMode, Pageable pageable) {
return findByRsqlAndDeleted(rsql, softDeletedMode, pageable);
}
@Override
public Page<JpaDistributionSet> findAll(SoftDeletedMode softDeletedMode, Pageable pageable) {
return jpaRepository.findAll(deletedSpecification(softDeletedMode), pageable);
}
@Override
public JpaDistributionSet update(final Update update) {
assertDistributionSetIsNotDeleted(jpaRepository.getById(update.getId()));
final JpaDistributionSet updated = super.update(update);
if (Boolean.TRUE.equals(update.getLocked())) {
lockSoftwareModules(updated);
@@ -154,6 +133,10 @@ public class JpaDistributionSetManagement
@Override
public Map<Long, JpaDistributionSet> update(final Collection<Update> updates) {
final List<Long> ids = updates.stream().map(Identifiable::getId).toList();
jpaRepository.findAllById(ids)
.forEach(this::assertDistributionSetIsNotDeleted);
final Map<Long, JpaDistributionSet> updated = super.update(updates);
for (final Update update : updates) {
final JpaDistributionSet updatedSet = updated.get(update.getId());
@@ -228,6 +211,8 @@ public class JpaDistributionSetManagement
@Retryable(includes = ConcurrencyFailureException.class, maxRetriesString = Constants.RETRY_MAX, delayString = Constants.RETRY_DELAY)
public JpaDistributionSet lock(final DistributionSet distributionSet) {
final JpaDistributionSet jpaDistributionSet = toJpaDistributionSet(distributionSet);
assertDistributionSetIsNotDeleted(jpaDistributionSet);
if (distributionSet.isLocked()) {
return jpaDistributionSet;
} else {
@@ -245,6 +230,8 @@ public class JpaDistributionSetManagement
@Retryable(includes = ConcurrencyFailureException.class, maxRetriesString = Constants.RETRY_MAX, delayString = Constants.RETRY_DELAY)
public JpaDistributionSet unlock(final DistributionSet distributionSet) {
final JpaDistributionSet jpaDistributionSet = toJpaDistributionSet(distributionSet);
assertDistributionSetIsNotDeleted(jpaDistributionSet);
if (jpaDistributionSet.isLocked()) {
jpaDistributionSet.setLocked(false);
return jpaRepository.save(jpaDistributionSet);
@@ -257,6 +244,7 @@ public class JpaDistributionSetManagement
@Transactional
public JpaDistributionSet invalidate(final DistributionSet distributionSet) {
final JpaDistributionSet jpaDistributionSet = toJpaDistributionSet(distributionSet);
assertDistributionSetIsNotDeleted(jpaDistributionSet);
jpaDistributionSet.invalidate();
return jpaRepository.save(jpaDistributionSet);
}
@@ -296,6 +284,7 @@ public class JpaDistributionSetManagement
@Retryable(includes = ConcurrencyFailureException.class, maxRetriesString = Constants.RETRY_MAX, delayString = Constants.RETRY_DELAY)
public List<JpaDistributionSet> assignTag(final Collection<Long> ids, final long dsTagId) {
return updateTag(ids, dsTagId, (tag, distributionSet) -> {
assertDistributionSetIsNotDeleted(distributionSet);
if (distributionSet.getTags().contains(tag)) {
return distributionSet;
} else {
@@ -310,6 +299,7 @@ public class JpaDistributionSetManagement
@Retryable(includes = ConcurrencyFailureException.class, maxRetriesString = Constants.RETRY_MAX, delayString = Constants.RETRY_DELAY)
public List<JpaDistributionSet> unassignTag(final Collection<Long> ids, final long dsTagId) {
return updateTag(ids, dsTagId, (tag, distributionSet) -> {
assertDistributionSetIsNotDeleted(distributionSet);
if (distributionSet.getTags().contains(tag)) {
distributionSet.removeTag(tag);
return jpaRepository.save(distributionSet);
@@ -332,9 +322,6 @@ public class JpaDistributionSetManagement
throw new IncompleteDistributionSetException(
"Distribution set of type " + distributionSet.getType().getKey() + " is incomplete: " + distributionSet.getId());
}
if (distributionSet.isDeleted()) {
throw new DeletedException(DistributionSet.class, id);
}
return distributionSet;
}
@@ -384,6 +371,62 @@ public class JpaDistributionSetManagement
QuotaHelper.assertAssignmentQuota(requested, maxMetaData, String.class, DistributionSet.class);
}
private Page<JpaDistributionSet> findByRsqlAndDeleted(String rsql, SoftDeletedMode deletedMode, Pageable pageable) {
if (rsql != null && rsql.toLowerCase().contains(COMPLETE)) {
// limited support for 'complete' - could be removed in future
final Node node = QLSupport.getInstance().parse(rsql);
final Specification<JpaDistributionSet> deletedSpec = deletedSpecification(deletedMode);
final List<Specification<JpaDistributionSet>> specList = new ArrayList<>();
specList.add(deletedSpec);
final AtomicReference<Node.Comparison> completedComparison = new AtomicReference<>();
if (node instanceof Node.Comparison comparison && COMPLETE.equalsIgnoreCase(comparison.getKey())) {
// all not deleted, won't add anything to spec
completedComparison.set(comparison);
} else if (node instanceof Node.Logical logical && logical.getOp() == Node.Logical.Operator.AND) {
final List<Node> sanitizedChildren = new ArrayList<>();
logical.getChildren().forEach(child -> {
if (child instanceof Node.Comparison comparison && COMPLETE.equalsIgnoreCase(comparison.getKey())) {
if (completedComparison.get() != null) {
throw new RSQLParameterSyntaxException("Multiple 'complete' comparisons are not supported");
}
completedComparison.set(comparison);
} else {
sanitizedChildren.add(child);
}
});
specList.add(QLSupport.getInstance().buildSpec(
sanitizedChildren.size() == 1
? sanitizedChildren.get(0)
: new Node.Logical(Node.Logical.Operator.AND, sanitizedChildren),
DistributionSetFields.class));
}
if (completedComparison.get() != null) { // really a comparison
log.warn("Usage of 'complete' is limited and may be removed: {}", node);
final boolean completed = completeComparison(completedComparison);
return filter(JpaManagementHelper.findAllWithCountBySpec(jpaRepository, specList, pageable), completed);
}
}
// fallback
final List<Specification<JpaDistributionSet>> specList = new ArrayList<>();
final Specification<JpaDistributionSet> rslqSpec =
rsql != null ? QLSupport.getInstance().buildSpec(rsql, DistributionSetFields.class) : Specification.unrestricted();
specList.add(deletedSpecification(deletedMode));
specList.add(rslqSpec);
return JpaManagementHelper.findAllWithCountBySpec(jpaRepository, specList, pageable);
}
private Specification<JpaDistributionSet> deletedSpecification(final SoftDeletedMode softDeletedMode) {
return switch (softDeletedMode) {
case ONLY_SOFT_DELETED ->
(root, query, cb) -> cb.equal(root.get(DELETED), true);
case EXCLUDE_SOFT_DELETED ->
(root, query, cb) -> cb.equal(root.get(DELETED), false);
case INCLUDE_SOFT_DELETED ->
Specification.unrestricted();
};
}
private static boolean completeComparison(final AtomicReference<Node.Comparison> completeComparison) {
final Node.Comparison comparison = completeComparison.get();
if (comparison.getOp() == Node.Comparison.Operator.EQ) {
@@ -410,6 +453,7 @@ public class JpaDistributionSetManagement
private JpaDistributionSet getValid0(final long id) {
final JpaDistributionSet distributionSet = jpaRepository.getById(id);
assertDistributionSetIsNotDeleted(distributionSet);
if (!distributionSet.isValid()) {
throw new InvalidDistributionSetException(
"Distribution set of type " + distributionSet.getType().getKey() + " is invalid: " + distributionSet.getId());
@@ -473,4 +517,10 @@ public class JpaDistributionSetManagement
throw new EntityNotFoundException(DistributionSetTag.class, tagId);
}
}
private void assertDistributionSetIsNotDeleted(final JpaDistributionSet jpaDistributionSet) {
if (jpaDistributionSet.isDeleted()) {
throw new DeletedException(DistributionSet.class, jpaDistributionSet.getId());
}
}
}

View File

@@ -11,15 +11,21 @@ package org.eclipse.hawkbit.repository.jpa.management;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import jakarta.persistence.EntityManager;
import org.eclipse.hawkbit.ql.jpa.QLSupport;
import org.eclipse.hawkbit.repository.DistributionSetTypeManagement;
import org.eclipse.hawkbit.repository.Identifiable;
import org.eclipse.hawkbit.repository.QuotaManagement;
import org.eclipse.hawkbit.repository.SoftDeletedMode;
import org.eclipse.hawkbit.repository.exception.AssignmentQuotaExceededException;
import org.eclipse.hawkbit.repository.exception.DeletedException;
import org.eclipse.hawkbit.repository.exception.EntityNotFoundException;
import org.eclipse.hawkbit.repository.exception.EntityReadOnlyException;
import org.eclipse.hawkbit.repository.jpa.JpaManagementHelper;
import org.eclipse.hawkbit.repository.jpa.acm.AccessController;
import org.eclipse.hawkbit.repository.jpa.configuration.Constants;
import org.eclipse.hawkbit.repository.jpa.model.JpaDistributionSetType;
@@ -38,6 +44,9 @@ import org.eclipse.hawkbit.tenancy.TenantAwareCacheManager;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.cache.Cache;
import org.springframework.dao.ConcurrencyFailureException;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.resilience.annotation.Retryable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -48,6 +57,7 @@ public class JpaDistributionSetTypeManagement
extends AbstractJpaRepositoryManagement<JpaDistributionSetType, DistributionSetTypeManagement.Create, DistributionSetTypeManagement.Update, DistributionSetTypeRepository, DistributionSetTypeFields>
implements DistributionSetTypeManagement<JpaDistributionSetType> {
private final DistributionSetTypeRepository distributionSetTypeRepository;
private final SoftwareModuleTypeRepository softwareModuleTypeRepository;
private final DistributionSetRepository distributionSetRepository;
private final TargetTypeRepository targetTypeRepository;
@@ -60,6 +70,7 @@ public class JpaDistributionSetTypeManagement
final DistributionSetRepository distributionSetRepository, final TargetTypeRepository targetTypeRepository,
final QuotaManagement quotaManagement) {
super(distributionSetTypeRepository, entityManager);
this.distributionSetTypeRepository = distributionSetTypeRepository;
this.softwareModuleTypeRepository = softwareModuleTypeRepository;
this.distributionSetRepository = distributionSetRepository;
this.targetTypeRepository = targetTypeRepository;
@@ -117,11 +128,53 @@ public class JpaDistributionSetTypeManagement
@Retryable(includes = ConcurrencyFailureException.class, maxRetriesString = Constants.RETRY_MAX, delayString = Constants.RETRY_DELAY)
public JpaDistributionSetType unassignSoftwareModuleType(final long id, final long softwareModuleTypeId) {
final JpaDistributionSetType type = jpaRepository.getById(id);
assertDistributionSetTypeIsNotDeleted(type);
checkDistributionSetTypeNotAssigned(id);
type.removeModuleType(softwareModuleTypeRepository.getById(softwareModuleTypeId));
return jpaRepository.save(type);
}
@Override
public Page<JpaDistributionSetType> findAll(SoftDeletedMode softDeletedMode, Pageable pageable) {
if (softDeletedMode != SoftDeletedMode.INCLUDE_SOFT_DELETED) {
final Specification<JpaDistributionSetType> deletedSpec =
DistributionSetTypeSpecification.isDeleted(softDeletedMode == SoftDeletedMode.ONLY_SOFT_DELETED);
return distributionSetTypeRepository.findAll(deletedSpec, pageable);
}
return distributionSetTypeRepository.findAll(pageable);
}
@Override
public Page<JpaDistributionSetType> findByRsql(String rsql, SoftDeletedMode softDeletedMode, Pageable pageable) {
final Specification<JpaDistributionSetType> rsqlSpec = QLSupport.getInstance().buildSpec(rsql, DistributionSetTypeFields.class);
if (softDeletedMode != SoftDeletedMode.INCLUDE_SOFT_DELETED) {
final Specification<JpaDistributionSetType> deletedSpec =
DistributionSetTypeSpecification.isDeleted(softDeletedMode == SoftDeletedMode.ONLY_SOFT_DELETED);
return distributionSetTypeRepository.findAll(JpaManagementHelper.combineWithAnd(List.of(rsqlSpec, deletedSpec)), pageable);
}
return distributionSetTypeRepository.findAll(rsqlSpec, pageable);
}
@Override
@Transactional
@Retryable(includes = ConcurrencyFailureException.class, maxRetriesString = Constants.RETRY_MAX, delayString = Constants.RETRY_DELAY)
public JpaDistributionSetType update(final DistributionSetTypeManagement.Update update) {
final JpaDistributionSetType distributionSetType = distributionSetTypeRepository.getById(update.getId());
assertDistributionSetTypeIsNotDeleted(distributionSetType);
return super.update(update);
}
@Override
@Transactional
@Retryable(includes = ConcurrencyFailureException.class, maxRetriesString = Constants.RETRY_MAX, delayString = Constants.RETRY_DELAY)
public Map<Long, JpaDistributionSetType> update(final Collection<DistributionSetTypeManagement.Update> updates) {
final List<Long> ids = updates.stream().map(Identifiable::getId).toList();
distributionSetTypeRepository.findAllById(ids).forEach(this::assertDistributionSetTypeIsNotDeleted);
return super.update(updates);
}
private JpaDistributionSetType assignSoftwareModuleTypes(
final long dsTypeId, final Collection<Long> softwareModulesTypeIds, final boolean mandatory) {
final Collection<JpaSoftwareModuleType> foundModules = softwareModuleTypeRepository.findAllById(softwareModulesTypeIds);
@@ -132,6 +185,7 @@ public class JpaDistributionSetTypeManagement
final JpaDistributionSetType type = jpaRepository.getById(dsTypeId);
assertDistributionSetTypeIsNotDeleted(type);
checkDistributionSetTypeNotAssigned(dsTypeId);
assertSoftwareModuleTypeQuota(dsTypeId, softwareModulesTypeIds.size());
@@ -168,4 +222,10 @@ public class JpaDistributionSetTypeManagement
"Distribution set type %s is already assigned to distribution sets and cannot be changed!", id));
}
}
private void assertDistributionSetTypeIsNotDeleted(final DistributionSetType distributionSetType){
if (distributionSetType.isDeleted()) {
throw new DeletedException(DistributionSetType.class, distributionSetType.getId());
}
}
}

View File

@@ -41,6 +41,7 @@ import org.eclipse.hawkbit.repository.RolloutApprovalStrategy;
import org.eclipse.hawkbit.repository.RolloutHelper;
import org.eclipse.hawkbit.repository.RolloutManagement;
import org.eclipse.hawkbit.repository.RolloutStatusCache;
import org.eclipse.hawkbit.repository.SoftDeletedMode;
import org.eclipse.hawkbit.repository.TargetManagement;
import org.eclipse.hawkbit.repository.event.EventPublisherHolder;
import org.eclipse.hawkbit.repository.event.remote.entity.RolloutGroupCreatedEvent;
@@ -266,6 +267,18 @@ public class JpaRolloutManagement implements RolloutManagement {
rolloutRepository.findAll(RolloutSpecification.isDeleted(deleted, pageable.getSort()), pageable), pageable);
}
@Override
public Page<Rollout> findAll(final SoftDeletedMode softDeletedMode, Pageable pageable) {
return switch (softDeletedMode) {
case EXCLUDE_SOFT_DELETED -> JpaManagementHelper.convertPage(
rolloutRepository.findAll(RolloutSpecification.isDeleted(false, pageable.getSort()), pageable), pageable);
case ONLY_SOFT_DELETED -> JpaManagementHelper.convertPage(
rolloutRepository.findAll(RolloutSpecification.isDeleted(true, pageable.getSort()), pageable), pageable);
case INCLUDE_SOFT_DELETED -> JpaManagementHelper.convertPage(
rolloutRepository.findAll(Specification.unrestricted(), pageable), pageable);
};
}
@Override
public Page<Rollout> findAllWithDetailedStatus(final boolean deleted, final Pageable pageable) {
return appendStatusDetails(JpaManagementHelper.convertPage(
@@ -281,6 +294,21 @@ public class JpaRolloutManagement implements RolloutManagement {
return JpaManagementHelper.convertPage(rolloutRepository.findAll(JpaManagementHelper.combineWithAnd(specList), pageable), pageable);
}
@Override
public Page<Rollout> findByRsql(String rsql, SoftDeletedMode softDeletedMode, Pageable pageable) {
final Specification<JpaRollout> rsqlSpec = QLSupport.getInstance().buildSpec(rsql, RolloutFields.class);
if (softDeletedMode != SoftDeletedMode.INCLUDE_SOFT_DELETED) {
final Specification<JpaRollout> deletedSpec = RolloutSpecification.isDeleted(
softDeletedMode == SoftDeletedMode.ONLY_SOFT_DELETED, pageable.getSort());
return JpaManagementHelper.convertPage(
rolloutRepository.findAll(JpaManagementHelper.combineWithAnd(List.of(rsqlSpec, deletedSpec)), pageable), pageable);
} else {
return JpaManagementHelper.convertPage(rolloutRepository.findAll(rsqlSpec, pageable), pageable);
}
}
@Override
public Page<Rollout> findByRsqlWithDetailedStatus(final String rsql, final boolean deleted, final Pageable pageable) {
final List<Specification<JpaRollout>> specList = List.of(

View File

@@ -22,9 +22,13 @@ import java.util.stream.Collectors;
import jakarta.persistence.EntityManager;
import org.eclipse.hawkbit.artifact.encryption.ArtifactEncryptionService;
import org.eclipse.hawkbit.ql.jpa.QLSupport;
import org.eclipse.hawkbit.repository.ArtifactManagement;
import org.eclipse.hawkbit.repository.Identifiable;
import org.eclipse.hawkbit.repository.QuotaManagement;
import org.eclipse.hawkbit.repository.SoftDeletedMode;
import org.eclipse.hawkbit.repository.SoftwareModuleManagement;
import org.eclipse.hawkbit.repository.exception.DeletedException;
import org.eclipse.hawkbit.repository.exception.EntityNotFoundException;
import org.eclipse.hawkbit.repository.exception.IncompleteSoftwareModuleException;
import org.eclipse.hawkbit.repository.exception.LockedException;
@@ -45,6 +49,7 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProp
import org.springframework.dao.ConcurrencyFailureException;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.resilience.annotation.Retryable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -56,6 +61,7 @@ public class JpaSoftwareModuleManagement extends
AbstractJpaRepositoryWithMetadataManagement<JpaSoftwareModule, SoftwareModuleManagement.Create, SoftwareModuleManagement.Update, SoftwareModuleRepository, SoftwareModuleFields, MetadataValue, JpaSoftwareModule.JpaMetadataValue>
implements SoftwareModuleManagement<JpaSoftwareModule> {
private final SoftwareModuleRepository softwareModuleRepository;
private final DistributionSetRepository distributionSetRepository;
private final ArtifactManagement artifactManagement;
private final QuotaManagement quotaManagement;
@@ -64,6 +70,7 @@ public class JpaSoftwareModuleManagement extends
final DistributionSetRepository distributionSetRepository, final ArtifactManagement artifactManagement,
final QuotaManagement quotaManagement) {
super(softwareModuleRepository, entityManager);
this.softwareModuleRepository = softwareModuleRepository;
this.distributionSetRepository = distributionSetRepository;
this.artifactManagement = artifactManagement;
this.quotaManagement = quotaManagement;
@@ -96,6 +103,25 @@ public class JpaSoftwareModuleManagement extends
return createdModule;
}
@Override
@Transactional
@Retryable(includes = ConcurrencyFailureException.class, maxRetriesString = Constants.RETRY_MAX, delayString = Constants.RETRY_DELAY)
public JpaSoftwareModule update(final Update update) {
final JpaSoftwareModule softwareModule = softwareModuleRepository.getById(update.getId());
assertSoftwareModuleIsNotDeleted(softwareModule);
return super.update(update);
}
@Override
@Transactional
@Retryable(includes = ConcurrencyFailureException.class, maxRetriesString = Constants.RETRY_MAX, delayString = Constants.RETRY_DELAY)
public Map<Long, JpaSoftwareModule> update(final Collection<SoftwareModuleManagement.Update> updates) {
final List<Long> ids = updates.stream().map(Identifiable::getId).toList();
softwareModuleRepository.findAllById(ids).forEach(this::assertSoftwareModuleIsNotDeleted);
return super.update(updates);
}
@Override
protected List<JpaSoftwareModule> softDelete(final Collection<JpaSoftwareModule> toDelete) {
return toDelete.stream().filter(swModule -> {
@@ -138,6 +164,7 @@ public class JpaSoftwareModuleManagement extends
@Retryable(includes = ConcurrencyFailureException.class, maxRetriesString = Constants.RETRY_MAX, delayString = Constants.RETRY_DELAY)
public JpaSoftwareModule lock(final SoftwareModule softwareModule) {
final JpaSoftwareModule jpaSoftwareModule = toJpaSoftwareModule(softwareModule);
assertSoftwareModuleIsNotDeleted(jpaSoftwareModule);
if (jpaSoftwareModule.isLocked()) {
return jpaSoftwareModule;
} else {
@@ -154,6 +181,7 @@ public class JpaSoftwareModuleManagement extends
@Retryable(includes = ConcurrencyFailureException.class, maxRetriesString = Constants.RETRY_MAX, delayString = Constants.RETRY_DELAY)
public JpaSoftwareModule unlock(final SoftwareModule softwareModule) {
final JpaSoftwareModule jpaSoftwareModule = toJpaSoftwareModule(softwareModule);
assertSoftwareModuleIsNotDeleted(jpaSoftwareModule);
if (softwareModule.isLocked()) {
jpaSoftwareModule.unlock();
return jpaRepository.save(jpaSoftwareModule);
@@ -162,6 +190,30 @@ public class JpaSoftwareModuleManagement extends
}
}
@Override
public Page<JpaSoftwareModule> findAll(SoftDeletedMode softDeletedMode, Pageable pageable) {
if (softDeletedMode != SoftDeletedMode.INCLUDE_SOFT_DELETED) {
Specification<JpaSoftwareModule> softDeletedSpec =
SoftwareModuleSpecification.isDeleted(softDeletedMode == SoftDeletedMode.ONLY_SOFT_DELETED);
return softwareModuleRepository.findAll(softDeletedSpec, pageable);
}
return softwareModuleRepository.findAll(pageable);
}
@Override
public Page<JpaSoftwareModule> findByRsql(String rsql, SoftDeletedMode softDeletedMode, Pageable pageable) {
final Specification<JpaSoftwareModule> rsqlSpec = QLSupport.getInstance().buildSpec(rsql, SoftwareModuleFields.class);
if (softDeletedMode != SoftDeletedMode.INCLUDE_SOFT_DELETED) {
final Specification<JpaSoftwareModule> softDeletedSpec =
SoftwareModuleSpecification.isDeleted(softDeletedMode == SoftDeletedMode.ONLY_SOFT_DELETED);
return softwareModuleRepository.findAll(
JpaManagementHelper.combineWithAnd(List.of(rsqlSpec, softDeletedSpec)), pageable);
}
return softwareModuleRepository.findAll(rsqlSpec, pageable);
}
@Override
public Page<JpaSoftwareModule> findByAssignedTo(final long distributionSetId, final Pageable pageable) {
assertDistributionSetExists(distributionSetId);
@@ -201,4 +253,10 @@ public class JpaSoftwareModuleManagement extends
throw new EntityNotFoundException(DistributionSet.class, distributionSetId);
}
}
private void assertSoftwareModuleIsNotDeleted(final SoftwareModule softwareModule) {
if (softwareModule.isDeleted()) {
throw new DeletedException(SoftwareModule.class, softwareModule.getId());
}
}
}

View File

@@ -10,20 +10,36 @@
package org.eclipse.hawkbit.repository.jpa.management;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import jakarta.persistence.EntityManager;
import org.eclipse.hawkbit.ql.jpa.QLSupport;
import org.eclipse.hawkbit.repository.Identifiable;
import org.eclipse.hawkbit.repository.SoftDeletedMode;
import org.eclipse.hawkbit.repository.SoftwareModuleTypeManagement;
import org.eclipse.hawkbit.repository.exception.DeletedException;
import org.eclipse.hawkbit.repository.jpa.JpaManagementHelper;
import org.eclipse.hawkbit.repository.jpa.configuration.Constants;
import org.eclipse.hawkbit.repository.jpa.model.JpaSoftwareModuleType;
import org.eclipse.hawkbit.repository.jpa.repository.DistributionSetTypeRepository;
import org.eclipse.hawkbit.repository.jpa.repository.SoftwareModuleRepository;
import org.eclipse.hawkbit.repository.jpa.repository.SoftwareModuleTypeRepository;
import org.eclipse.hawkbit.repository.jpa.specifications.SoftwareModuleTypeSpecification;
import org.eclipse.hawkbit.repository.model.SoftwareModuleType;
import org.eclipse.hawkbit.repository.qfields.SoftwareModuleTypeFields;
import org.eclipse.hawkbit.tenancy.TenantAwareCacheManager;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.cache.Cache;
import org.springframework.dao.ConcurrencyFailureException;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.resilience.annotation.Retryable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@ConditionalOnBooleanProperty(prefix = "hawkbit.jpa", name = { "enabled", "software-module-type=management" }, matchIfMissing = true)
@@ -31,6 +47,7 @@ public class JpaSoftwareModuleTypeManagement
extends AbstractJpaRepositoryManagement<JpaSoftwareModuleType, SoftwareModuleTypeManagement.Create, SoftwareModuleTypeManagement.Update, SoftwareModuleTypeRepository, SoftwareModuleTypeFields>
implements SoftwareModuleTypeManagement<JpaSoftwareModuleType> {
private final SoftwareModuleTypeRepository softwareModuleTypeRepository;
private final DistributionSetTypeRepository distributionSetTypeRepository;
private final SoftwareModuleRepository softwareModuleRepository;
@@ -40,6 +57,7 @@ public class JpaSoftwareModuleTypeManagement
final DistributionSetTypeRepository distributionSetTypeRepository,
final SoftwareModuleRepository softwareModuleRepository) {
super(softwareModuleTypeRepository, entityManager);
this.softwareModuleTypeRepository = softwareModuleTypeRepository;
this.distributionSetTypeRepository = distributionSetTypeRepository;
this.softwareModuleRepository = softwareModuleRepository;
}
@@ -54,9 +72,56 @@ public class JpaSoftwareModuleTypeManagement
return jpaRepository.findByKey(key);
}
@Override
public Page<JpaSoftwareModuleType> findAll(SoftDeletedMode softDeletedMode, Pageable pageable) {
if (softDeletedMode != SoftDeletedMode.INCLUDE_SOFT_DELETED) {
final Specification<JpaSoftwareModuleType> deletedSpec =
SoftwareModuleTypeSpecification.isDeleted(softDeletedMode == SoftDeletedMode.ONLY_SOFT_DELETED);
return softwareModuleTypeRepository.findAll(deletedSpec, pageable);
}
return softwareModuleTypeRepository.findAll(pageable);
}
@Override
public Page<JpaSoftwareModuleType> findByRsql(String rsql, SoftDeletedMode softDeletedMode, Pageable pageable) {
final Specification<JpaSoftwareModuleType> rsqlSpec = QLSupport.getInstance().buildSpec(rsql, SoftwareModuleTypeFields.class);
if (softDeletedMode != SoftDeletedMode.INCLUDE_SOFT_DELETED) {
final Specification<JpaSoftwareModuleType> deletedSpec =
SoftwareModuleTypeSpecification.isDeleted(softDeletedMode == SoftDeletedMode.ONLY_SOFT_DELETED);
return softwareModuleTypeRepository.findAll(JpaManagementHelper.combineWithAnd(List.of(rsqlSpec, deletedSpec)), pageable);
}
return softwareModuleTypeRepository.findAll(rsqlSpec, pageable);
}
@Override
@Transactional
@Retryable(includes = ConcurrencyFailureException.class, maxRetriesString = Constants.RETRY_MAX, delayString = Constants.RETRY_DELAY)
public JpaSoftwareModuleType update(final SoftwareModuleTypeManagement.Update update) {
final JpaSoftwareModuleType softwareModuleType = softwareModuleTypeRepository.getById(update.getId());
assertSoftwareModuleTypeIsNotDeleted(softwareModuleType);
return super.update(update);
}
@Override
@Transactional
@Retryable(includes = ConcurrencyFailureException.class, maxRetriesString = Constants.RETRY_MAX, delayString = Constants.RETRY_DELAY)
public Map<Long, JpaSoftwareModuleType> update(final Collection<SoftwareModuleTypeManagement.Update> updates) {
final List<Long> ids = updates.stream().map(Identifiable::getId).toList();
softwareModuleTypeRepository.findAllById(ids).forEach(this::assertSoftwareModuleTypeIsNotDeleted);
return super.update(updates);
}
@Override
protected Collection<JpaSoftwareModuleType> softDelete(final Collection<JpaSoftwareModuleType> toDelete) {
return toDelete.stream().filter(smt ->
softwareModuleRepository.countByType(smt) > 0 || distributionSetTypeRepository.countByElementsSmType(smt) > 0).toList();
}
private void assertSoftwareModuleTypeIsNotDeleted(final JpaSoftwareModuleType softwareModuleType){
if (softwareModuleType.isDeleted()) {
throw new DeletedException(SoftwareModuleType.class, softwareModuleType.getId());
}
}
}

View File

@@ -13,6 +13,7 @@ import lombok.AccessLevel;
import lombok.NoArgsConstructor;
import org.eclipse.hawkbit.repository.jpa.model.AbstractJpaTypeEntity_;
import org.eclipse.hawkbit.repository.jpa.model.JpaDistributionSetType;
import org.eclipse.hawkbit.repository.jpa.model.JpaDistributionSetType_;
import org.eclipse.hawkbit.repository.model.DistributionSet;
import org.eclipse.hawkbit.repository.model.DistributionSetType;
import org.springframework.data.jpa.domain.Specification;
@@ -34,4 +35,9 @@ public final class DistributionSetTypeSpecification {
public static Specification<JpaDistributionSetType> byKey(final String key) {
return (targetRoot, query, cb) -> cb.equal(targetRoot.get(AbstractJpaTypeEntity_.key), key);
}
public static Specification<JpaDistributionSetType> isDeleted(final Boolean isDeleted) {
return (root, query, cb) ->
cb.equal(root.<Boolean> get(JpaDistributionSetType_.deleted), isDeleted);
}
}

View File

@@ -32,4 +32,9 @@ public final class SoftwareModuleSpecification {
return cb.equal(join.get(AbstractJpaBaseEntity_.ID), dsId);
};
}
public static Specification<JpaSoftwareModule> isDeleted(final Boolean isDeleted) {
return (root, query, cb) ->
cb.equal(root.<Boolean> get(JpaSoftwareModule_.deleted), isDeleted);
}
}

View File

@@ -0,0 +1,25 @@
/**
* Copyright (c) 2026 Contributors to the Eclipse Foundation
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.repository.jpa.specifications;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
import org.eclipse.hawkbit.repository.jpa.model.JpaSoftwareModuleType;
import org.eclipse.hawkbit.repository.jpa.model.JpaSoftwareModuleType_;
import org.springframework.data.jpa.domain.Specification;
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class SoftwareModuleTypeSpecification {
public static Specification<JpaSoftwareModuleType> isDeleted(final Boolean isDeleted) {
return (root, query, cb) ->
cb.equal(root.<Boolean> get(JpaSoftwareModuleType_.deleted), isDeleted);
}
}

View File

@@ -457,16 +457,16 @@ public abstract class AbstractRepositoryManagementTest<T extends BaseEntity, C,
if (resolvableType.getRawClass() == targetSuperClass) {
return Optional.of(resolvableType);
}
final Optional<ResolvableType> inInterfaces = Arrays.stream(resolvableType.getInterfaces())
.filter(superInterface -> superInterface.getRawClass() == targetSuperClass)
.findAny();
if (inInterfaces.isPresent()) {
return inInterfaces;
} else if (resolvableType.getSuperType() != ResolvableType.NONE) {
return findGenericSuperType(resolvableType.getSuperType(), targetSuperClass);
} else {
return Optional.empty();
for (final ResolvableType superInterface : resolvableType.getInterfaces()) {
final Optional<ResolvableType> found = findGenericSuperType(superInterface, targetSuperClass);
if (found.isPresent()) {
return found;
}
}
if (resolvableType.getSuperType() != ResolvableType.NONE) {
return findGenericSuperType(resolvableType.getSuperType(), targetSuperClass);
}
return Optional.empty();
}
private void assertEquals(final Object actual, final Object expected, final Deque<String> path) {

View File

@@ -32,6 +32,7 @@ import org.eclipse.hawkbit.repository.DistributionSetManagement.Update;
import org.eclipse.hawkbit.repository.DistributionSetTagManagement;
import org.eclipse.hawkbit.repository.DistributionSetTypeManagement;
import org.eclipse.hawkbit.repository.Identifiable;
import org.eclipse.hawkbit.repository.exception.DeletedException;
import org.eclipse.hawkbit.repository.RepositoryProperties;
import org.eclipse.hawkbit.repository.SoftwareModuleManagement;
import org.eclipse.hawkbit.repository.SoftwareModuleTypeManagement;
@@ -936,4 +937,19 @@ class DistributionSetManagementTest extends AbstractRepositoryManagementWithMeta
.as("entity with too short version should not be updated")
.isThrownBy(() -> distributionSetManagement.update(distributionSetUpdate2));
}
@Test
void bulkUpdateSoftDeletedDistributionSetRejected() {
final DistributionSet active = testdataFactory.createDistributionSet("active");
final DistributionSet toDelete = testdataFactory.createDistributionSet("toDelete");
testdataFactory.createTarget("bulkTarget");
assignDistributionSet(toDelete.getId(), "bulkTarget");
distributionSetManagement.delete(toDelete.getId());
final List<Update> updates = List.of(
Update.builder().id(active.getId()).description("ok").build(),
Update.builder().id(toDelete.getId()).description("should fail").build());
assertThatThrownBy(() -> distributionSetManagement.update(updates))
.isInstanceOf(DeletedException.class);
}
}

View File

@@ -32,6 +32,7 @@ import org.eclipse.hawkbit.repository.SoftwareModuleManagement.Create;
import org.eclipse.hawkbit.repository.SoftwareModuleManagement.Update;
import org.eclipse.hawkbit.repository.SoftwareModuleTypeManagement;
import org.eclipse.hawkbit.repository.event.remote.entity.SoftwareModuleCreatedEvent;
import org.eclipse.hawkbit.repository.exception.DeletedException;
import org.eclipse.hawkbit.repository.exception.IncompleteSoftwareModuleException;
import org.eclipse.hawkbit.repository.exception.LockedException;
import org.eclipse.hawkbit.repository.jpa.RandomGeneratedInputStream;
@@ -486,6 +487,22 @@ class SoftwareModuleManagementTest
}
}
@Test
void bulkUpdateSoftDeletedSoftwareModuleRejected() {
final SoftwareModule active = softwareModuleManagement.create(
Create.builder().type(osType).name("active").version("1.0").build());
SoftwareModule toDelete = softwareModuleManagement.create(
Create.builder().type(osType).name("toDelete").version("1.0").build());
testdataFactory.createDistributionSet(List.of(toDelete));
softwareModuleManagement.delete(toDelete.getId());
final List<Update> updates = List.of(
Update.builder().id(active.getId()).description("ok").build(),
Update.builder().id(toDelete.getId()).description("should fail").build());
assertThatExceptionOfType(DeletedException.class)
.isThrownBy(() -> softwareModuleManagement.update(updates));
}
private void assertArtifactDoesntExist(final Artifact... results) {
for (final Artifact result : results) {
final String currentTenant = AccessContext.tenant();