Add support for plugable QL for EntityManager (#2698)

Signed-off-by: Avgustin Marinov <Avgustin.Marinov@bosch.com>
This commit is contained in:
Avgustin Marinov
2025-09-26 15:39:21 +03:00
committed by GitHub
parent 4434484d35
commit 1abfa0a2f4
10 changed files with 45 additions and 42 deletions

View File

@@ -31,7 +31,6 @@ import java.util.Objects;
import java.util.function.BiPredicate;
import org.eclipse.hawkbit.repository.jpa.ql.Node.Comparison.Operator;
import org.eclipse.hawkbit.repository.jpa.rsql.RsqlParser;
/**
* Provides entity matcher that matches an entity object against a filter (a {@link Node} or an RSQL string).
@@ -44,14 +43,10 @@ public class EntityMatcher {
this.root = root;
}
public static EntityMatcher forNode(final Node root) {
public static EntityMatcher of(final Node root) {
return new EntityMatcher(root);
}
public static EntityMatcher forRsql(final String rsql) {
return forNode(RsqlParser.parse(rsql));
}
public <T> boolean match(final T t) {
return match(t, root);
}

View File

@@ -149,8 +149,7 @@ public class QLSupport {
* given {@code fieldNameProvider}
* @throws RSQLParameterSyntaxException if the RSQL syntax is wrong
*/
public <A extends Enum<A> & QueryField, T> Specification<T> buildSpec(
final String query, final Class<A> queryFieldType) {
public <A extends Enum<A> & QueryField, T> Specification<T> buildSpec(final String query, final Class<A> queryFieldType) {
if (specBuilder == SpecBuilder.G3) {
return new SpecificationBuilder<T>(!caseInsensitiveDB && ignoreCase, database)
.specification(parser.parse(caseInsensitiveDB || ignoreCase ? query.toLowerCase() : query, queryFieldType));
@@ -159,6 +158,10 @@ public class QLSupport {
}
}
public <A extends Enum<A> & QueryField> EntityMatcher entityMatcher(final String query, final Class<A> queryFieldType) {
return EntityMatcher.of(parser.parse(caseInsensitiveDB || ignoreCase ? query.toLowerCase() : query, queryFieldType));
}
/**
* Validates the query string
*
@@ -168,7 +171,7 @@ public class QLSupport {
* @throws QueryException if query is invalid
*/
@SuppressWarnings({ "unchecked", "rawtypes" })
public <A extends Enum<A> & QueryField> void validateQuery(final String query, final Class<A> queryFieldType, final Class<?> jpaType) {
public <A extends Enum<A> & QueryField> void validate(final String query, final Class<A> queryFieldType, final Class<?> jpaType) {
final CriteriaBuilder criteriaBuilder = entityManager.getCriteriaBuilder();
final CriteriaQuery<?> criteriaQuery = criteriaBuilder.createQuery(jpaType);
buildSpec(query, queryFieldType).toPredicate(criteriaQuery.from((Class) jpaType), criteriaQuery, criteriaBuilder);

View File

@@ -50,14 +50,14 @@ public class SpecificationBuilderLegacy<A extends Enum<A> & QueryField, T> {
public Specification<T> specification(final String rsql) {
return (root, query, cb) -> {
final QLSupport rsqlUtility = QLSupport.getInstance();
final QLSupport qlSupport = QLSupport.getInstance();
final Node rootNode = parseRsql(rsql, rsqlUtility);
final Node rootNode = parseRsql(rsql, qlSupport);
query.distinct(true);
final boolean ensureIgnoreCase = !rsqlUtility.isCaseInsensitiveDB() && rsqlUtility.isIgnoreCase();
final boolean ensureIgnoreCase = !qlSupport.isCaseInsensitiveDB() && qlSupport.isIgnoreCase();
final RSQLVisitor<List<Predicate>, String> jpqQueryRSQLVisitor =
rsqlUtility.getSpecBuilder() == LEGACY_G1
qlSupport.getSpecBuilder() == LEGACY_G1
? new JpaQueryRsqlVisitor<>(root, cb, rsqlQueryFieldType, virtualPropertyReplacer, database, query, ensureIgnoreCase)
: new JpaQueryRsqlVisitorG2<>(rsqlQueryFieldType, root, query, cb, database, virtualPropertyReplacer, ensureIgnoreCase);
final List<Predicate> accept = rootNode.accept(jpqQueryRSQLVisitor);

View File

@@ -553,7 +553,7 @@ class SpecificationBuilderTest {
private List<Root> filter(final String rsql) {
// reference / auto filter (using elements and reflection)
final EntityMatcher matcher = EntityMatcher.forRsql(rsql);
final EntityMatcher matcher = EntityMatcher.of(RsqlParser.parse(rsql));
final List<Root> refResult = StreamSupport.stream(rootRepository.findAll().spliterator(), false).filter(matcher::match).toList();
final List<Root> result = rootRepository.findAll(getSpecification(rsql));
// auto check with reference result

View File

@@ -23,7 +23,6 @@ import lombok.extern.slf4j.Slf4j;
import org.eclipse.hawkbit.ContextAware;
import org.eclipse.hawkbit.repository.QueryField;
import org.eclipse.hawkbit.repository.exception.InsufficientPermissionException;
import org.eclipse.hawkbit.repository.jpa.ql.EntityMatcher;
import org.eclipse.hawkbit.repository.jpa.ql.QLSupport;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.jpa.domain.Specification;
@@ -34,17 +33,17 @@ import org.springframework.util.ObjectUtils;
@Slf4j
public class DefaultAccessController<A extends Enum<A> & QueryField, T> implements AccessController<T> {
private final Class<A> rsqlQueryFieldType;
private final Class<A> queryFieldType;
private final Map<Operation, List<String>> permissions = new EnumMap<>(Operation.class);
private ContextAware contextAware;
public DefaultAccessController(final Class<A> rsqlQueryFieldType, final String... permissionTypes) {
public DefaultAccessController(final Class<A> queryFieldType, final String... permissionTypes) {
if (ObjectUtils.isEmpty(permissionTypes)) {
throw new IllegalArgumentException("Permission types must not be empty");
}
this.rsqlQueryFieldType = rsqlQueryFieldType;
this.queryFieldType = queryFieldType;
for (final Operation operation : Operation.values()) {
for (final String permissionType : permissionTypes) {
permissions.computeIfAbsent(operation, k -> new ArrayList<>()).add(operation.name() + "_" + permissionType.toUpperCase());
@@ -69,7 +68,7 @@ public class DefaultAccessController<A extends Enum<A> & QueryField, T> implemen
scopes.size() == 1
? scopes.get(0) // single scope
: "(" + String.join(") or (", scopes) + ")") // join multiple scopes with 'or' - union
.map(rsql -> QLSupport.getInstance().buildSpec(rsql, rsqlQueryFieldType));
.map(rsql -> QLSupport.getInstance().buildSpec(rsql, queryFieldType));
}
@Override
@@ -82,7 +81,7 @@ public class DefaultAccessController<A extends Enum<A> & QueryField, T> implemen
final List<String> scopes = getScopes(operation);
if (scopes != null) {
for (final String scope : scopes) {
if (EntityMatcher.forRsql(scope).match(entity)) {
if (QLSupport.getInstance().entityMatcher(scope, queryFieldType).match(entity)) {
return; // at least one scope matches, operation is allowed
}
}
@@ -110,7 +109,7 @@ public class DefaultAccessController<A extends Enum<A> & QueryField, T> implemen
// * in case the entity permission(s) are implied - e.g. there is READ_REPOSITORY which implies READ_DISTRIBUTION_SET
log.debug(
"[{}] No matching authority found for operation {} (expects {}), they shall have already been checked with @PreAuthorize)",
rsqlQueryFieldType, operation, operationPermissions);
queryFieldType, operation, operationPermissions);
return null;
} else if (scopes.contains(null)) {
return null; // not scoped at all

View File

@@ -55,6 +55,7 @@ import org.eclipse.hawkbit.repository.RepositoryConstants;
import org.eclipse.hawkbit.repository.RepositoryProperties;
import org.eclipse.hawkbit.repository.SecurityTokenGeneratorHolder;
import org.eclipse.hawkbit.repository.SoftwareModuleManagement;
import org.eclipse.hawkbit.repository.TargetFields;
import org.eclipse.hawkbit.repository.TenantConfigurationManagement;
import org.eclipse.hawkbit.repository.UpdateMode;
import org.eclipse.hawkbit.repository.event.EventPublisherHolder;
@@ -78,7 +79,7 @@ import org.eclipse.hawkbit.repository.jpa.model.JpaAction_;
import org.eclipse.hawkbit.repository.jpa.model.JpaDistributionSet;
import org.eclipse.hawkbit.repository.jpa.model.JpaTarget;
import org.eclipse.hawkbit.repository.jpa.model.JpaTarget_;
import org.eclipse.hawkbit.repository.jpa.ql.EntityMatcher;
import org.eclipse.hawkbit.repository.jpa.ql.QLSupport;
import org.eclipse.hawkbit.repository.jpa.repository.ActionRepository;
import org.eclipse.hawkbit.repository.jpa.repository.ActionStatusRepository;
import org.eclipse.hawkbit.repository.jpa.repository.SoftwareModuleRepository;
@@ -391,7 +392,7 @@ public class JpaControllerManagement extends JpaActionManagement implements Cont
if (!ObjectUtils.isEmpty(pollingTime.getOverrides()) && target instanceof JpaTarget jpaTarget) {
for (final PollingTime.Override override : pollingTime.getOverrides()) {
try {
if (EntityMatcher.forRsql(override.qlStr()).match(jpaTarget)) {
if (QLSupport.getInstance().entityMatcher(override.qlStr(), TargetFields.class).match(jpaTarget)) {
return override.pollingInterval().getFormattedIntervalWithDeviation(minPollingTime, maxPollingTime);
}
} catch (final Exception e) {

View File

@@ -117,7 +117,7 @@ class JpaTargetFilterQueryManagement
@Override
public void verifyTargetFilterQuerySyntax(final String query) {
try {
QLSupport.getInstance().validateQuery(query, TargetFields.class, JpaTarget.class);
QLSupport.getInstance().validate(query, TargetFields.class, JpaTarget.class);
} catch (final RSQLParserException | RSQLParameterUnsupportedFieldException e) {
log.debug("The RSQL query '{}}' is invalid.", query, e);
throw new RSQLParameterSyntaxException("Cannot create a Rollout with an empty target query filter!");
@@ -225,7 +225,7 @@ class JpaTargetFilterQueryManagement
});
Optional.ofNullable(create.getQuery()).ifPresent(query -> {
// validate the RSQL query syntax
QLSupport.getInstance().validateQuery(query, TargetFields.class, JpaTarget.class);
QLSupport.getInstance().validate(query, TargetFields.class, JpaTarget.class);
// enforce the 'max targets per auto assign' quota right here even if the result of the filter query can vary over time
Optional.ofNullable(create.getAutoAssignDistributionSet()).ifPresent(dsId -> {
@@ -242,7 +242,7 @@ class JpaTargetFilterQueryManagement
final JpaTargetFilterQuery targetFilterQuery = jpaRepository.getById(update.getId());
Optional.ofNullable(update.getQuery()).ifPresent(query -> {
// validate the RSQL query syntax
QLSupport.getInstance().validateQuery(query, TargetFields.class, JpaTarget.class);
QLSupport.getInstance().validate(query, TargetFields.class, JpaTarget.class);
Optional.ofNullable(targetFilterQuery.getAutoAssignDistributionSet()).ifPresent(autoAssignDs -> {
// enforce the 'max targets per auto assignment'-quota only if the query is going to change

View File

@@ -111,7 +111,7 @@ public class JpaTargetManagement
@Override
public boolean isTargetMatchingQueryAndDSNotAssignedAndCompatibleAndUpdatable(
final String controllerId, final long distributionSetId, final String targetFilterQuery) {
QLSupport.getInstance().validateQuery(targetFilterQuery, TargetFields.class, JpaTarget.class);
QLSupport.getInstance().validate(targetFilterQuery, TargetFields.class, JpaTarget.class);
final DistributionSet ds = distributionSetManagement.get(distributionSetId);
final Long distSetTypeId = ds.getType().getId();
final List<Specification<JpaTarget>> specList = List.of(

View File

@@ -29,6 +29,7 @@ import java.util.stream.Collectors;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.hawkbit.im.authentication.SpPermission;
import org.eclipse.hawkbit.repository.TargetFields;
import org.eclipse.hawkbit.repository.TenantConfigurationManagement;
import org.eclipse.hawkbit.repository.event.remote.TenantConfigurationDeletedEvent;
import org.eclipse.hawkbit.repository.event.remote.entity.RemoteEntityEvent;
@@ -39,7 +40,7 @@ import org.eclipse.hawkbit.repository.jpa.configuration.Constants;
import org.eclipse.hawkbit.repository.jpa.executor.AfterTransactionCommitExecutor;
import org.eclipse.hawkbit.repository.jpa.model.JpaTarget;
import org.eclipse.hawkbit.repository.jpa.model.JpaTenantConfiguration;
import org.eclipse.hawkbit.repository.jpa.ql.EntityMatcher;
import org.eclipse.hawkbit.repository.jpa.ql.QLSupport;
import org.eclipse.hawkbit.repository.jpa.repository.TenantConfigurationRepository;
import org.eclipse.hawkbit.repository.model.PollStatus;
import org.eclipse.hawkbit.repository.model.Target;
@@ -204,7 +205,7 @@ public class JpaTenantConfigurationManagement implements TenantConfigurationMana
if (!ObjectUtils.isEmpty(pollingTime.getOverrides()) && target instanceof JpaTarget jpaTarget) {
for (final PollingTime.Override override : pollingTime.getOverrides()) {
try {
if (EntityMatcher.forRsql(override.qlStr()).match(jpaTarget)) {
if (QLSupport.getInstance().entityMatcher(override.qlStr(), TargetFields.class).match(jpaTarget)) {
return pollStatus(lastTargetQuery, override.pollingInterval(), pollingOverdueTime);
}
} catch (final Exception e) {
@@ -271,8 +272,8 @@ public class JpaTenantConfigurationManagement implements TenantConfigurationMana
if (!ObjectUtils.isEmpty(pollingTime.getOverrides())) {
// validate that the QL strings are valid RSQL queries,
// nevertheless always when parse them we shall be prepared to catch exceptions if the parsers
// has been changed in non backward compatible way
pollingTime.getOverrides().forEach(override -> EntityMatcher.forRsql(override.qlStr()));
// has been changed in not backward compatible way
pollingTime.getOverrides().forEach(override -> QLSupport.getInstance().entityMatcher(override.qlStr(), TargetFields.class));
}
}
@@ -291,8 +292,8 @@ public class JpaTenantConfigurationManagement implements TenantConfigurationMana
return jpaTenantConfigurations.stream().collect(Collectors.toMap(
JpaTenantConfiguration::getKey,
updatedTenantConfiguration -> {
@SuppressWarnings("unchecked")
final Class<T> clazzT = (Class<T>) configurations.get(updatedTenantConfiguration.getKey()).getClass();
@SuppressWarnings("unchecked") final Class<T> clazzT = (Class<T>) configurations.get(updatedTenantConfiguration.getKey())
.getClass();
return TenantConfigurationValue.<T> builder().global(false)
.createdBy(updatedTenantConfiguration.getCreatedBy())
.createdAt(updatedTenantConfiguration.getCreatedAt())
@@ -351,7 +352,9 @@ public class JpaTenantConfigurationManagement implements TenantConfigurationMana
if (MULTI_ASSIGNMENTS_ENABLED.equals(key) && Boolean.parseBoolean(valueChange.getValue())) {
JpaTenantConfiguration batchConfig = tenantConfigurationRepository.findByKey(BATCH_ASSIGNMENTS_ENABLED);
if (batchConfig != null && Boolean.parseBoolean(batchConfig.getValue())) {
log.debug("The Multi-Assignments '{}' feature cannot be enabled as it contradicts with the Batch-Assignments feature, which is already enabled .", key);
log.debug(
"The Multi-Assignments '{}' feature cannot be enabled as it contradicts with the Batch-Assignments feature, which is already enabled .",
key);
throw new TenantConfigurationValueChangeNotAllowedException();
}
}
@@ -361,7 +364,9 @@ public class JpaTenantConfigurationManagement implements TenantConfigurationMana
if (BATCH_ASSIGNMENTS_ENABLED.equals(key) && Boolean.parseBoolean(valueChange.getValue())) {
JpaTenantConfiguration multiConfig = tenantConfigurationRepository.findByKey(MULTI_ASSIGNMENTS_ENABLED);
if (multiConfig != null && Boolean.parseBoolean(multiConfig.getValue())) {
log.debug("The Batch-Assignments '{}' feature cannot be enabled as it contradicts with the Multi-Assignments feature, which is already enabled .", key);
log.debug(
"The Batch-Assignments '{}' feature cannot be enabled as it contradicts with the Multi-Assignments feature, which is already enabled .",
key);
throw new TenantConfigurationValueChangeNotAllowedException();
}
}

View File

@@ -375,26 +375,26 @@ class RsqlTargetFieldTest extends AbstractJpaIntegrationTest {
*/
@Test
void rsqlValidTargetFields() {
QLSupport.getInstance().validateQuery(
QLSupport.getInstance().validate(
"ID == '0123' and NAME == abcd and DESCRIPTION == absd and CREATEDAT =lt= 0123 and LASTMODIFIEDAT =gt= 0123" +
" and CONTROLLERID == 0123 and UPDATESTATUS == PENDING and IPADDRESS == 0123 and LASTCONTROLLERREQUESTAT == 0123" +
" and tag == beta",
TargetFields.class, JpaTarget.class);
QLSupport.getInstance().validateQuery(
QLSupport.getInstance().validate(
"ASSIGNEDDS.name == abcd and ASSIGNEDDS.version == 0123 and INSTALLEDDS.name == abcd and INSTALLEDDS.version == 0123",
TargetFields.class, JpaTarget.class);
QLSupport.getInstance().validateQuery(
QLSupport.getInstance().validate(
"ATTRIBUTE.subkey1 == test and ATTRIBUTE.subkey2 == test and METADATA.metakey1 == abcd and METADATA.metavalue2 == asdfg",
TargetFields.class, JpaTarget.class);
QLSupport.getInstance().validateQuery(
QLSupport.getInstance().validate(
"CREATEDAT =lt= ${NOW_TS} and LASTMODIFIEDAT =ge= ${OVERDUE_TS}",
TargetFields.class, JpaTarget.class);
QLSupport.getInstance().validateQuery(
QLSupport.getInstance().validate(
"ATTRIBUTE.test.dot == test and ATTRIBUTE.subkey2 == test and METADATA.test.dot == abcd and METADATA.metavalue2 == asdfg",
TargetFields.class, JpaTarget.class);
assertThatExceptionOfType(RSQLParameterUnsupportedFieldException.class)
.isThrownBy(() -> QLSupport.getInstance().validateQuery("wrongfield == abcd", TargetFields.class, JpaTarget.class));
.isThrownBy(() -> QLSupport.getInstance().validate("wrongfield == abcd", TargetFields.class, JpaTarget.class));
}
/**
@@ -449,6 +449,6 @@ class RsqlTargetFieldTest extends AbstractJpaIntegrationTest {
private void assertRSQLQueryThrowsException(final String rsql) {
assertThatExceptionOfType(RSQLParameterUnsupportedFieldException.class)
.isThrownBy(() -> QLSupport.getInstance().validateQuery(rsql, TargetFields.class, JpaTarget.class));
.isThrownBy(() -> QLSupport.getInstance().validate(rsql, TargetFields.class, JpaTarget.class));
}
}