diff --git a/hawkbit-repository/hawkbit-repository-jpa-ql/src/main/java/org/eclipse/hawkbit/repository/jpa/ql/Node.java b/hawkbit-repository/hawkbit-repository-jpa-ql/src/main/java/org/eclipse/hawkbit/repository/jpa/ql/Node.java index b7608c0ba..673d6a0c3 100644 --- a/hawkbit-repository/hawkbit-repository-jpa-ql/src/main/java/org/eclipse/hawkbit/repository/jpa/ql/Node.java +++ b/hawkbit-repository/hawkbit-repository-jpa-ql/src/main/java/org/eclipse/hawkbit/repository/jpa/ql/Node.java @@ -39,16 +39,16 @@ public interface Node { return op(this, Logical.Operator.OR, other); } - // utility method that maps this node with a mapper that could modify comparisons - e.g. change keys, values, operators, or whatever + // utility method that maps this node with a transformer that could modify comparisons - e.g. change keys, values, operators, or whatever // if there are no changes the same instance is returned - default Node map(final UnaryOperator mapper) { + default Node transform(final UnaryOperator transformer) { if (this instanceof Comparison comparison) { - return mapper.apply(comparison); + return transformer.apply(comparison); } else { final List mappedChildren = new ArrayList<>(); boolean modified = false; for (final Node child : ((Logical) this).getChildren()) { - final Node mapped = child.map(mapper); + final Node mapped = child.transform(transformer); mappedChildren.add(mapped); if (!mapped.equals(child)) { modified = true; diff --git a/hawkbit-repository/hawkbit-repository-jpa-ql/src/main/java/org/eclipse/hawkbit/repository/jpa/ql/QLSupport.java b/hawkbit-repository/hawkbit-repository-jpa-ql/src/main/java/org/eclipse/hawkbit/repository/jpa/ql/QLSupport.java index 9bb459882..c9692c3cb 100644 --- a/hawkbit-repository/hawkbit-repository-jpa-ql/src/main/java/org/eclipse/hawkbit/repository/jpa/ql/QLSupport.java +++ b/hawkbit-repository/hawkbit-repository-jpa-ql/src/main/java/org/eclipse/hawkbit/repository/jpa/ql/QLSupport.java @@ -20,21 +20,24 @@ import jakarta.persistence.criteria.CriteriaQuery; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.NonNull; import lombok.Setter; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.text.StrLookup; -import org.eclipse.hawkbit.repository.ActionFields; import org.eclipse.hawkbit.repository.QueryField; import org.eclipse.hawkbit.repository.exception.QueryException; import org.eclipse.hawkbit.repository.exception.RSQLParameterSyntaxException; import org.eclipse.hawkbit.repository.exception.RSQLParameterUnsupportedFieldException; import org.eclipse.hawkbit.repository.jpa.ql.Node.Comparison; -import org.eclipse.hawkbit.repository.jpa.rsql.RsqlParser; import org.eclipse.hawkbit.repository.jpa.rsql.legacy.SpecificationBuilderLegacy; import org.eclipse.hawkbit.repository.rsql.VirtualPropertyResolver; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.orm.jpa.JpaProperties; +import org.springframework.context.ApplicationListener; +import org.springframework.context.event.ContextRefreshedEvent; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; import org.springframework.data.jpa.domain.Specification; import org.springframework.orm.jpa.vendor.Database; @@ -75,7 +78,7 @@ import org.springframework.orm.jpa.vendor.Database; @Getter @NoArgsConstructor(access = AccessLevel.PRIVATE) @SuppressWarnings("java:S6548") // singleton holder ensures static access to spring resources in some places -public class QLSupport { +public class QLSupport implements ApplicationListener { private static final QLSupport SINGLETON = new QLSupport(); @@ -110,6 +113,7 @@ public class QLSupport { @SuppressWarnings({ "rawtypes", "unchecked" }) private QueryParser parser; + private List nodeTransformers; private Database database; private EntityManager entityManager; private VirtualPropertyResolver virtualPropertyResolver; @@ -126,6 +130,15 @@ public class QLSupport { this.parser = parser; } + @Override + public void onApplicationEvent(@NonNull final ContextRefreshedEvent event) { + nodeTransformers = event.getApplicationContext().getBeansOfType(NodeTransformer.class).values().stream().sorted((b1, b2) -> { + final Order o1 = b1.getClass().getAnnotation(Order.class); + final Order o2 = b2.getClass().getAnnotation(Order.class); + return Integer.compare(o1 != null ? o1.value() : Ordered.LOWEST_PRECEDENCE, o2 != null ? o2.value() : Ordered.LOWEST_PRECEDENCE); + }).toList(); + } + @Autowired void setDatabase(final JpaProperties jpaProperties) { database = jpaProperties.getDatabase(); @@ -155,7 +168,7 @@ public class QLSupport { public & QueryField, T> Specification buildSpec(final String query, final Class queryFieldType) { if (specBuilder == SpecBuilder.G3) { return new SpecificationBuilder(!caseInsensitiveDB && ignoreCase, database) - .specification(parser.parse(caseInsensitiveDB || ignoreCase ? query.toLowerCase() : query, queryFieldType)); + .specification(parseAndTransform(query, queryFieldType, caseInsensitiveDB || ignoreCase)); } else { return new SpecificationBuilderLegacy(queryFieldType, virtualPropertyResolver, database).specification(query); } @@ -164,7 +177,7 @@ public class QLSupport { @SuppressWarnings({ "java:S1117" }) // it is again ignoreCase public & QueryField> EntityMatcher entityMatcher(final String query, final Class queryFieldType) { final boolean ignoreCase = caseInsensitiveDB || this.ignoreCase; // sync with DB and case sensitivity requirements - return EntityMatcher.of(parser.parse(ignoreCase ? query.toLowerCase() : query, queryFieldType), ignoreCase); + return EntityMatcher.of(parseAndTransform(query, queryFieldType, ignoreCase), ignoreCase); } /** @@ -182,69 +195,82 @@ public class QLSupport { buildSpec(query, queryFieldType).toPredicate(criteriaQuery.from((Class) jpaType), criteriaQuery, criteriaBuilder); } + private & QueryField> Node parseAndTransform( + final String query, final Class queryFieldType, final boolean ignoreCase) { + Node node = parser.parse(ignoreCase ? query.toLowerCase() : query, queryFieldType); + for (final NodeTransformer transformer : nodeTransformers) { + node = transformer.transform(node, queryFieldType); + } + return node; + } + + /** + * By registering a custom {@link QueryParser} (as a {@link org.springframework.context.annotation.Bean}) the entire parsing of the queries + * could be replaced / customized, e.g. the default query language (RSQL) could be replaced with a custom. + */ public interface QueryParser { & QueryField> Node parse(final String query, final Class queryFieldType) throws QueryException; } - public static class DefaultQueryParser implements QueryParser { + /** + * By registering a custom {@link NodeTransformer} (as a {@link org.springframework.context.annotation.Bean}) the nodes could be + * modified after parsing, e.g. to add implicit nodes or to modify values. + *

+ * By default, all transformers are with {@link Ordered#LOWEST_PRECEDENCE} order. So, if you need a specific order use the {@link Order} + * annotation of their class (not on the bean registering methods). + */ + public interface NodeTransformer { - @Override - public & QueryField> Node parse(final String query, final Class queryFieldType) throws QueryException { - return RsqlParser.parse(query, queryFieldType).map(comparison -> map(comparison, queryFieldType)); - } + & QueryField> Node transform(Node node, final Class queryFieldType); - protected & QueryField> Comparison map(final Comparison comparison, final Class queryFieldType) { - final String key = mapKey(comparison.getKey(), comparison, queryFieldType).toString(); - final Object value = mapValue(comparison.getValue(), comparison, queryFieldType); - return key.equals(comparison.getKey()) && Objects.equals(value, comparison.getValue()) - ? comparison : Comparison.builder().key(key).op(comparison.getOp()).value(value).build(); - } + /** + * Base implementation that does no real transformation but allows extenders to easily modify keys and / or values by simply extending + * the extension points. + */ + abstract class Abstract implements NodeTransformer { - // just extension points for subclasses - protected & QueryField> Object mapKey(final String key, final Comparison comparison, final Class queryFieldType) { - return key; - } - - // internal, override only if you really want to replace whole lists - protected & QueryField> Object mapValue( - final Object value, final Comparison comparison, final Class queryFieldType) { - if (value instanceof List list) { - final List mappedList = new ArrayList<>(); - boolean modified = false; - for (final Object e : list) { - final Object mapped = mapSimpleValue(e, comparison, queryFieldType); - if (!Objects.equals(mapped, value)) { - modified = true; - } - mappedList.add(mapped); - } - return modified ? mappedList : list; - } else { - return mapSimpleValue(value, comparison, queryFieldType); + public & QueryField> Node transform(final Node node, final Class queryFieldType) { + return node.transform(comparison -> transform(comparison, queryFieldType)); } - } - // just extension points for subclasses - protected & QueryField> Object mapSimpleValue( - final Object value, final Comparison comparison, final Class queryFieldType) { - return queryFieldType == (Class) ActionFields.class && "active".equalsIgnoreCase(comparison.getKey()) - ? mapActionStatus(value) - : value; - } + protected & QueryField> Comparison transform(final Comparison comparison, final Class queryFieldType) { + final String key = transformKey(comparison.getKey(), comparison, queryFieldType).toString(); + final Object value = transformValue(comparison.getValue(), comparison, queryFieldType); + return key.equals(comparison.getKey()) && Objects.equals(value, comparison.getValue()) + ? comparison : Comparison.builder().key(key).op(comparison.getOp()).value(value).build(); + } - private static Object mapActionStatus(final Object value) { - final String strValue = String.valueOf(value); - if ("true".equalsIgnoreCase(strValue) || "false".equalsIgnoreCase(strValue)) { - return value; - } else { - // handle custom action fields status - try { - return ActionFields.convertStatusValue(strValue); - } catch (final IllegalArgumentException e) { - throw new RSQLParameterUnsupportedFieldException(e.getMessage()); + // just extension points for subclasses + protected & QueryField> Object transformKey( + final String key, final Comparison comparison, final Class queryFieldType) { + return key; + } + + // internal, override only if you really want to replace whole lists + protected & QueryField> Object transformValue( + final Object value, final Comparison comparison, final Class queryFieldType) { + if (value instanceof List list) { + final List mappedList = new ArrayList<>(); + boolean modified = false; + for (final Object e : list) { + final Object mapped = transformValueElement(e, comparison, queryFieldType); + if (!Objects.equals(mapped, value)) { + modified = true; + } + mappedList.add(mapped); + } + return modified ? mappedList : list; + } else { + return transformValueElement(value, comparison, queryFieldType); } } + + // just extension points for subclasses + protected & QueryField> Object transformValueElement( + final Object value, final Comparison comparison, final Class queryFieldType) { + return value; + } } } } \ No newline at end of file diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/JpaRepositoryConfiguration.java b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/JpaRepositoryConfiguration.java index 8480efa68..243853fcb 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/JpaRepositoryConfiguration.java +++ b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/JpaRepositoryConfiguration.java @@ -23,6 +23,7 @@ import org.eclipse.hawkbit.ContextAware; import org.eclipse.hawkbit.artifact.encryption.ArtifactEncryption; import org.eclipse.hawkbit.artifact.encryption.ArtifactEncryptionSecretsStorage; import org.eclipse.hawkbit.artifact.encryption.ArtifactEncryptionService; +import org.eclipse.hawkbit.repository.ActionFields; import org.eclipse.hawkbit.repository.DeploymentManagement; import org.eclipse.hawkbit.repository.PropertiesQuotaManagement; import org.eclipse.hawkbit.repository.QueryField; @@ -45,6 +46,7 @@ import org.eclipse.hawkbit.repository.event.ApplicationEventFilter; import org.eclipse.hawkbit.repository.event.remote.EventEntityManager; import org.eclipse.hawkbit.repository.event.remote.EventEntityManagerHolder; import org.eclipse.hawkbit.repository.event.remote.TargetPollEvent; +import org.eclipse.hawkbit.repository.exception.RSQLParameterUnsupportedFieldException; import org.eclipse.hawkbit.repository.jpa.acm.AccessController; import org.eclipse.hawkbit.repository.jpa.aspects.ExceptionMappingAspectHandler; import org.eclipse.hawkbit.repository.jpa.autoassign.AutoAssignChecker; @@ -69,12 +71,13 @@ import org.eclipse.hawkbit.repository.jpa.model.helper.AfterTransactionCommitExe import org.eclipse.hawkbit.repository.jpa.model.helper.EntityInterceptorHolder; import org.eclipse.hawkbit.repository.jpa.model.helper.TenantAwareHolder; import org.eclipse.hawkbit.repository.jpa.ql.Node.Comparison; -import org.eclipse.hawkbit.repository.jpa.ql.QLSupport.DefaultQueryParser; +import org.eclipse.hawkbit.repository.jpa.ql.QLSupport; +import org.eclipse.hawkbit.repository.jpa.ql.QLSupport.NodeTransformer; import org.eclipse.hawkbit.repository.jpa.ql.QLSupport.QueryParser; import org.eclipse.hawkbit.repository.jpa.repository.ActionRepository; +import org.eclipse.hawkbit.repository.jpa.repository.ArtifactRepository; import org.eclipse.hawkbit.repository.jpa.repository.DistributionSetRepository; import org.eclipse.hawkbit.repository.jpa.repository.DistributionSetTypeRepository; -import org.eclipse.hawkbit.repository.jpa.repository.ArtifactRepository; import org.eclipse.hawkbit.repository.jpa.repository.RolloutGroupRepository; import org.eclipse.hawkbit.repository.jpa.repository.RolloutRepository; import org.eclipse.hawkbit.repository.jpa.repository.RolloutTargetGroupRepository; @@ -90,7 +93,7 @@ import org.eclipse.hawkbit.repository.jpa.rollout.condition.RolloutGroupEvaluati import org.eclipse.hawkbit.repository.jpa.rollout.condition.StartNextGroupRolloutGroupSuccessAction; import org.eclipse.hawkbit.repository.jpa.rollout.condition.ThresholdRolloutGroupErrorCondition; import org.eclipse.hawkbit.repository.jpa.rollout.condition.ThresholdRolloutGroupSuccessCondition; -import org.eclipse.hawkbit.repository.jpa.ql.QLSupport; +import org.eclipse.hawkbit.repository.jpa.rsql.RsqlParser; import org.eclipse.hawkbit.repository.jpa.utils.ExceptionMapper; import org.eclipse.hawkbit.repository.model.RolloutGroup; import org.eclipse.hawkbit.repository.model.SoftwareModule; @@ -109,6 +112,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.config.BeanPostProcessor; import org.springframework.boot.autoconfigure.AutoConfigureAfter; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.domain.EntityScan; @@ -525,16 +529,54 @@ public class JpaRepositoryConfiguration { } @Bean - @ConditionalOnMissingBean - QueryParser queryParser(final Optional virtualPropertyResolver) { - return virtualPropertyResolver.map(resolver -> new DefaultQueryParser() { + @ConditionalOnBean(VirtualPropertyResolver.class) + public NodeTransformer virtualPropertyReplacerTransformer(final VirtualPropertyResolver resolver) { + return new NodeTransformer.Abstract() { @Override - protected & QueryField> Object mapValue( + protected & QueryField> Object transformValueElement( final Object value, final Comparison comparison, final Class queryFieldType) { - return super.mapValue(value instanceof String strValue ? resolver.replace(strValue) : value, comparison, queryFieldType); + return value instanceof String strValue ? resolver.replace(strValue) : value; } - }).orElseGet(DefaultQueryParser::new); + }; + } + + /** + * @deprecated since 0.10.0, will be removed in future releases. Use "active" for querying active status instead of "status". + */ + @Deprecated(since = "0.10.0", forRemoval = true) + @Bean + public NodeTransformer actionStatusTransformer() { + return new NodeTransformer.Abstract() { + + // just extension points for subclasses + protected & QueryField> Object transformValueElement( + final Object value, final Comparison comparison, final Class queryFieldType) { + return queryFieldType == (Class) ActionFields.class && "active".equalsIgnoreCase(comparison.getKey()) + ? mapActionStatus(value) + : value; + } + + private static Object mapActionStatus(final Object value) { + final String strValue = String.valueOf(value); + if ("true".equalsIgnoreCase(strValue) || "false".equalsIgnoreCase(strValue)) { + return value; + } else { + // handle custom action fields status + try { + return ActionFields.convertStatusValue(strValue); + } catch (final IllegalArgumentException e) { + throw new RSQLParameterUnsupportedFieldException(e.getMessage()); + } + } + } + }; + } + + @Bean + @ConditionalOnMissingBean + QueryParser queryParser() { + return RsqlParser::parse; } /**