* Rsql G3
* Move legacy and G2 visors in rsqllegacy and deprecate
* Refactor RSQLUtility

Signed-off-by: Avgustin Marinov <Avgustin.Marinov@bosch.com>

---------

Signed-off-by: Avgustin Marinov <Avgustin.Marinov@bosch.com>
This commit is contained in:
Avgustin Marinov
2025-06-02 10:08:13 +03:00
committed by GitHub
parent a8a67910ac
commit c3aa2b7ae7
18 changed files with 1330 additions and 235 deletions

View File

@@ -322,15 +322,10 @@ class MgmtActionResourceTest extends AbstractManagementApiIntegrationTest {
final DistributionSet ds = testdataFactory.createDistributionSet("");
assignDistributionSet(ds, Collections.singletonList(target));
final String rsqlTargetControllerId = "target.controllerId==knownTargetId";
final String rsqlTargetName = "target.name==knownTargetName";
final String rsqlTargetUpdateStatus = "target.updateStatus==pending";
final String rsqlTargetAddress = "target.address==http://0.0.0.0";
verifyResultsByTargetPropertyFilter(target, ds, rsqlTargetControllerId);
verifyResultsByTargetPropertyFilter(target, ds, rsqlTargetName);
verifyResultsByTargetPropertyFilter(target, ds, rsqlTargetUpdateStatus);
verifyResultsByTargetPropertyFilter(target, ds, rsqlTargetAddress);
verifyResultsByTargetPropertyFilter(target, ds, "target.controllerId==knownTargetId");
verifyResultsByTargetPropertyFilter(target, ds, "target.name==knownTargetName");
verifyResultsByTargetPropertyFilter(target, ds, "target.updateStatus==pending");
verifyResultsByTargetPropertyFilter(target, ds, "target.address==http://0.0.0.0");
}
@Test

View File

@@ -11,6 +11,7 @@ package org.eclipse.hawkbit.repository.rsql;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.springframework.beans.factory.annotation.Value;
/**
@@ -23,6 +24,11 @@ public final class RsqlConfigHolder {
private static final RsqlConfigHolder SINGLETON = new RsqlConfigHolder();
public enum RsqlToSpecBuilder {
LEGACY_G1, // legacy RSQL visitor
LEGACY_G2, // G2 RSQL visitor
G3 // G3 RSQL visitor - still experimental / yet default
}
/**
* If RSQL comparison operators shall ignore the case. If ignore case is <code>true</code> "x == ax" will match "x == aX"
*/
@@ -39,11 +45,12 @@ public final class RsqlConfigHolder {
private boolean caseInsensitiveDB;
/**
* @deprecated in favour of G2 RSQL visitor. since 0.6.0
* @deprecated in favour fixed final visitor / spec builder of G2 RSQL visitor / G3 spec builder. since 0.6.0
*/
@Setter // for tests only
@Deprecated(forRemoval = true, since = "0.6.0")
@Value("${hawkbit.rsql.legacy-rsql-visitor:false}")
private boolean legacyRsqlVisitor;
@Value("${hawkbit.rsql.rsql-to-spec-builder:G3}") //
private RsqlToSpecBuilder rsqlToSpecBuilder;
/**
* @return The holder singleton instance.

View File

@@ -0,0 +1,211 @@
/**
* Copyright (c) 2025 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.rsql;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import jakarta.annotation.Nonnull;
import jakarta.annotation.Nullable;
import lombok.Builder;
import lombok.Value;
/**
* Node is always in Disjunctive Normal Form (DNF). This means:
* <ul>
* <li>single comparison</li>
* <li>and composition of comparisons</li>
* <li>or of comparisons or and composition of comparisons</li>
* </ul>
* Node: if there are custom implementation they shall also follow this contract!
*/
public interface Node {
default Logical and(final Node other) {
return op(this, Logical.Operator.AND, other);
}
default Logical or(final Node other) {
return op(this, Logical.Operator.OR, other);
}
static Logical op(final Node node1, final Logical.Operator op, final Node node2) {
if (node1 instanceof Logical logical1 && logical1.getOp() == op) {
if (node2 instanceof Logical logical2 && logical2.getOp() == op) { // same op
final List<Node> children = new ArrayList<>(logical1.getChildren().size() + logical2.getChildren().size());
children.addAll(logical1.getChildren());
children.addAll(logical2.getChildren());
return new Logical(op, children);
} else { // node2 is not a logical
final List<Node> children = new ArrayList<>(logical1.getChildren().size() + 1);
children.addAll(logical1.getChildren());
children.add(node2);
return new Logical(op, children);
}
} else if (node2 instanceof Logical logical2 && logical2.getOp() == op) {
final List<Node> children = new ArrayList<>(logical2.getChildren().size() + 1);
children.add(node1);
children.addAll(logical2.getChildren());
return new Logical(op, children);
} else {
return new Logical(op, List.of(node1, node2));
}
}
@Value
class Logical implements Node {
public enum Operator {
AND("&&"),
OR("||");
private final String symbol;
Operator(String symbol) {
this.symbol = symbol;
}
@Override
public String toString() {
return symbol;
}
}
Logical.Operator op;
List<Node> children;
public Logical(final Operator op, final List<Node> children) {
Objects.requireNonNull(op, "Operator must not be null");
if (children == null || children.size() < 2) {
throw new IllegalArgumentException("Children of a logical must not be null or empty or single element list");
}
this.op = op;
this.children = children;
}
@Override
public String toString() {
return children.stream()
.map(child -> op == Operator.OR || child instanceof Comparison
? child.toString()
: "(" + child.toString() + ")")
.reduce((a, b) -> a + " " + op + " " + b)
.orElse("");
}
}
@Value
@Builder(builderClassName = "Builder")
class Comparison implements Node {
public enum Operator {
EQ("=="),
NE("!="),
GT(">"),
LT("<"),
GTE(">="),
LTE("<="),
IN("in"),
NOT_IN("not in"),
LIKE("like"),
NOT_LIKE("not like");
private final String symbol;
Operator(String symbol) {
this.symbol = symbol;
}
@Override
public String toString() {
return symbol;
}
}
@Nonnull
String key;
@Nonnull
Operator op;
@Nullable
Object value; // String, Number, Boolean, String[] or Number[] or null
@Nullable
Object context;
public Comparison(@Nonnull final String key, @Nonnull final Operator op, @Nullable final Object value) {
this(key, op, value, null);
}
public Comparison(@Nonnull final String key, @Nonnull final Operator op, @Nullable final Object value, @Nullable final Object context) {
Objects.requireNonNull(key, "Key must not be null");
if (key.trim().isEmpty()) {
throw new IllegalArgumentException("Key must not be empty");
}
Objects.requireNonNull(op, "Operator must not be null");
if (value == null) {
switch (op) {
case GT, LT, LIKE, NOT_LIKE:
throw new IllegalArgumentException("Value must not be null for operator " + op);
default: // ok
}
} else if (value instanceof List<?> list) {
switch (op) {
case IN, NOT_IN:
break;
default:
throw new IllegalArgumentException("Value must not be a list for operator " + op);
}
if (list.isEmpty()) {
throw new IllegalArgumentException("List value must not be empty");
}
list.forEach(listElement -> {
if (notValid(listElement)) {
throw new IllegalArgumentException("List type value must be String or Number");
}
});
} else if (notValid(value)) {
throw new IllegalArgumentException(
"Value must be String, Number, Boolean, non-empty String list, non-empty Number list or null");
}
this.key = key.trim();
this.op = op;
this.value = value;
this.context = context;
}
@Override
public String toString() {
return key + " " + op + " " + valueToString(value);
}
private static String valueToString(final Object value) {
if (value == null) {
return "null";
} else if (value instanceof String) {
return String.format("\"%s\"", value);
} else if (value instanceof List<?> list) {
return "(" + list.stream()
.map(Comparison::valueToString)
.reduce((a, b) -> a + ", " + b)
.orElse("") + ")";
} else {
return value.toString();
}
}
private static boolean notValid(final Object value) {
return !(value instanceof String) && !(value instanceof Number) && !(value instanceof Boolean);
}
}
}

View File

@@ -1,45 +0,0 @@
/**
* Copyright (c) 2015 Bosch Software Innovations GmbH and others
*
* 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.rsql;
import java.util.HashMap;
import java.util.Map;
/**
* Maps property of entity to its alias .
*/
public final class PropertyMapper {
private static Map<Class<?>, Map<String, String>> allowedColumns = new HashMap<>();
private PropertyMapper() {
}
/**
* Add new mapping - property name and alias.
*
* @param type entity type
* @param property alias of property
* @param mapping property name
*/
public static void addNewMapping(final Class<?> type, final String property, final String mapping) {
allowedColumns.computeIfAbsent(type, k -> new HashMap<>());
allowedColumns.get(type).put(property, mapping);
}
/**
* @return the allowedcolmns
*/
public static Map<Class<?>, Map<String, String>> getAllowedcolmns() {
return allowedColumns;
}
}

View File

@@ -9,11 +9,11 @@
*/
package org.eclipse.hawkbit.repository.jpa.rsql;
import static org.eclipse.hawkbit.repository.rsql.RsqlConfigHolder.RsqlToSpecBuilder.G3;
import static org.eclipse.hawkbit.repository.rsql.RsqlConfigHolder.RsqlToSpecBuilder.LEGACY_G1;
import java.io.Serial;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import jakarta.persistence.EntityManager;
import jakarta.persistence.criteria.CriteriaBuilder;
@@ -23,9 +23,7 @@ import jakarta.persistence.criteria.Root;
import cz.jirutka.rsql.parser.RSQLParser;
import cz.jirutka.rsql.parser.RSQLParserException;
import cz.jirutka.rsql.parser.ast.ComparisonOperator;
import cz.jirutka.rsql.parser.ast.Node;
import cz.jirutka.rsql.parser.ast.RSQLOperators;
import cz.jirutka.rsql.parser.ast.RSQLVisitor;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
@@ -34,6 +32,9 @@ import org.apache.commons.lang3.text.StrLookup;
import org.eclipse.hawkbit.repository.RsqlQueryField;
import org.eclipse.hawkbit.repository.exception.RSQLParameterSyntaxException;
import org.eclipse.hawkbit.repository.exception.RSQLParameterUnsupportedFieldException;
import org.eclipse.hawkbit.repository.jpa.rsqllegacy.JpaQueryRsqlVisitor;
import org.eclipse.hawkbit.repository.jpa.rsqllegacy.JpaQueryRsqlVisitorG2;
import org.eclipse.hawkbit.repository.jpa.rsqllegacy.SpecificationBuilderLegacy;
import org.eclipse.hawkbit.repository.rsql.RsqlConfigHolder;
import org.eclipse.hawkbit.repository.rsql.VirtualPropertyReplacer;
import org.eclipse.hawkbit.repository.rsql.VirtualPropertyResolver;
@@ -79,12 +80,11 @@ import org.springframework.util.CollectionUtils;
public final class RSQLUtility {
/**
* Builds a JPA {@link Specification} which corresponds with the given RSQL
* query. The specification can be used to filter for JPA entities with the
* given RSQL query.
* Builds a JPA {@link Specification} which corresponds with the given RSQL query. The specification can be used to filter for JPA entities
* with the given RSQL query.
*
* @param rsql the rsql query to be parsed
* @param fieldNameProvider the enum class type which implements the {@link RsqlQueryField}
* @param rsqlQueryFieldType the enum class type which implements the {@link RsqlQueryField}
* @param virtualPropertyReplacer holds the logic how the known macros have to be resolved; may be <code>null</code>
* @param database database in use
* @return a specification which can be used with JPA
@@ -93,98 +93,34 @@ public final class RSQLUtility {
* @throws RSQLParameterSyntaxException if the RSQL syntax is wrong
*/
public static <A extends Enum<A> & RsqlQueryField, T> Specification<T> buildRsqlSpecification(
final String rsql, final Class<A> fieldNameProvider,
final String rsql, final Class<A> rsqlQueryFieldType,
final VirtualPropertyReplacer virtualPropertyReplacer, final Database database) {
return new RSQLSpecification<>(rsql, fieldNameProvider, virtualPropertyReplacer, database);
if (RsqlConfigHolder.getInstance().getRsqlToSpecBuilder() == G3) {
return new SpecificationBuilder<T>(
virtualPropertyReplacer,
!RsqlConfigHolder.getInstance().isCaseInsensitiveDB() && RsqlConfigHolder.getInstance().isIgnoreCase(),
database).specification(RsqlParser.parse(rsql, rsqlQueryFieldType));
} else {
return new SpecificationBuilderLegacy<A, T>(rsqlQueryFieldType, virtualPropertyReplacer, database).specification(rsql);
}
}
/**
* Validates the RSQL string
*
* @param rsql RSQL string to validate
* @param fieldNameProvider
* @param rsqlQueryFieldType
* @throws RSQLParserException if RSQL syntax is invalid
* @throws RSQLParameterUnsupportedFieldException if RSQL key is not allowed
*/
@SuppressWarnings({"unchecked", "rawtypes"})
public static <A extends Enum<A> & RsqlQueryField> void validateRsqlFor(
final String rsql, final Class<A> fieldNameProvider,
final String rsql, final Class<A> rsqlQueryFieldType,
final Class<?> jpaType,
final VirtualPropertyReplacer virtualPropertyReplacer, final EntityManager entityManager) {
final CriteriaBuilder criteriaBuilder = entityManager.getCriteriaBuilder();
final CriteriaQuery<?> criteriaQuery = criteriaBuilder.createQuery(jpaType);
new RSQLSpecification<>(rsql, fieldNameProvider, virtualPropertyReplacer, null)
buildRsqlSpecification(rsql, rsqlQueryFieldType, virtualPropertyReplacer, null)
.toPredicate(criteriaQuery.from((Class)jpaType), criteriaQuery, criteriaBuilder);
}
static final ComparisonOperator IS = new ComparisonOperator("=is=", "=eq=");
static final ComparisonOperator NOT = new ComparisonOperator("=not=", "=ne=");
private static final Set<ComparisonOperator> OPERATORS;
static {
final Set<ComparisonOperator> operators = new HashSet<>(RSQLOperators.defaultOperators());
// == and != alternatives just treating "null" string as null not as a "null"
operators.add(IS);
operators.add(NOT);
OPERATORS = Collections.unmodifiableSet(operators);
}
private static final class RSQLSpecification<A extends Enum<A> & RsqlQueryField, T> implements Specification<T> {
@Serial
private static final long serialVersionUID = 1L;
private final String rsql;
private final Class<A> enumType;
private final VirtualPropertyReplacer virtualPropertyReplacer;
private final Database database;
private RSQLSpecification(
final String rsql, final Class<A> enumType,
final VirtualPropertyReplacer virtualPropertyReplacer, final Database database) {
this.rsql = rsql;
this.enumType = enumType;
this.virtualPropertyReplacer = virtualPropertyReplacer;
this.database = database;
}
@Override
public Predicate toPredicate(final Root<T> root, final CriteriaQuery<?> query, final CriteriaBuilder cb) {
final Node rootNode = parseRsql(rsql);
query.distinct(true);
final RSQLVisitor<List<Predicate>, String> jpqQueryRSQLVisitor =
RsqlConfigHolder.getInstance().isLegacyRsqlVisitor()
? new JpaQueryRsqlVisitor<>(
root, cb, enumType,
virtualPropertyReplacer, database, query,
!RsqlConfigHolder.getInstance().isCaseInsensitiveDB() && RsqlConfigHolder.getInstance().isIgnoreCase())
: new JpaQueryRsqlVisitorG2<>(
enumType, root, query, cb,
database, virtualPropertyReplacer,
!RsqlConfigHolder.getInstance().isCaseInsensitiveDB() && RsqlConfigHolder.getInstance()
.isIgnoreCase());
final List<Predicate> accept = rootNode.accept(jpqQueryRSQLVisitor);
if (CollectionUtils.isEmpty(accept)) {
return cb.conjunction();
} else {
return cb.and(accept.toArray(new Predicate[0]));
}
}
}
private static Node parseRsql(final String rsql) {
log.debug("Parsing rsql string {}", rsql);
try {
return new RSQLParser(OPERATORS).parse(
RsqlConfigHolder.getInstance().isCaseInsensitiveDB() || RsqlConfigHolder.getInstance().isIgnoreCase()
? rsql.toLowerCase()
: rsql);
} catch (final IllegalArgumentException e) {
throw new RSQLParameterSyntaxException("RSQL filter must not be null", e);
} catch (final RSQLParserException e) {
throw new RSQLParameterSyntaxException(e);
}
}
}

View File

@@ -0,0 +1,270 @@
/**
* Copyright (c) 2025 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.rsql;
import static org.eclipse.hawkbit.repository.RsqlQueryField.SUB_ATTRIBUTE_SEPARATOR;
import static org.eclipse.hawkbit.repository.jpa.rsql.Node.Comparison.Operator.EQ;
import static org.eclipse.hawkbit.repository.jpa.rsql.Node.Comparison.Operator.GT;
import static org.eclipse.hawkbit.repository.jpa.rsql.Node.Comparison.Operator.GTE;
import static org.eclipse.hawkbit.repository.jpa.rsql.Node.Comparison.Operator.IN;
import static org.eclipse.hawkbit.repository.jpa.rsql.Node.Comparison.Operator.LIKE;
import static org.eclipse.hawkbit.repository.jpa.rsql.Node.Comparison.Operator.LT;
import static org.eclipse.hawkbit.repository.jpa.rsql.Node.Comparison.Operator.LTE;
import static org.eclipse.hawkbit.repository.jpa.rsql.Node.Comparison.Operator.NE;
import static org.eclipse.hawkbit.repository.jpa.rsql.Node.Comparison.Operator.NOT_IN;
import static org.eclipse.hawkbit.repository.jpa.rsql.Node.Comparison.Operator.NOT_LIKE;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.function.Function;
import java.util.function.UnaryOperator;
import jakarta.annotation.Nullable;
import cz.jirutka.rsql.parser.RSQLParser;
import cz.jirutka.rsql.parser.RSQLParserException;
import cz.jirutka.rsql.parser.ast.AndNode;
import cz.jirutka.rsql.parser.ast.ComparisonNode;
import cz.jirutka.rsql.parser.ast.ComparisonOperator;
import cz.jirutka.rsql.parser.ast.OrNode;
import cz.jirutka.rsql.parser.ast.RSQLOperators;
import cz.jirutka.rsql.parser.ast.RSQLVisitor;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.hawkbit.repository.FieldValueConverter;
import org.eclipse.hawkbit.repository.RsqlQueryField;
import org.eclipse.hawkbit.repository.exception.RSQLParameterSyntaxException;
import org.eclipse.hawkbit.repository.exception.RSQLParameterUnsupportedFieldException;
import org.eclipse.hawkbit.repository.jpa.rsql.Node.Comparison;
import org.eclipse.hawkbit.repository.rsql.RsqlConfigHolder;
/**
* {@link RsqlParser} parses RSQL query stings to {@link Node} objects. Doing that it does the following:
* <ul>
* <li>check for null value and pass it properly (IS, NOT)</li>
* <li>check for * and convert EQ/NEQ to LIKE/NOT_LIKE</li>
* <li>replace the rsql fields (enum values) with the JPA entity field names</li>
* <li>checks sub-attributes (if allowed)</li>
* <li>append the default sub-attributes if needed)</li>
* <li>apply value conversion for implementing with FieldValueConverter</li>
* </ul>
*/
@NoArgsConstructor(access = AccessLevel.PRIVATE)
@Slf4j
public class RsqlParser {
public static final Character LIKE_WILDCARD = '*';
public static final ComparisonOperator IS = new ComparisonOperator("=is=", "=eq=");
public static final ComparisonOperator NOT = new ComparisonOperator("=not=", "=ne=");
private static final RSQLParser PARSER;
static {
final Set<ComparisonOperator> operators = new HashSet<>(RSQLOperators.defaultOperators());
// == and != alternatives just treating "null" string as null not as a "null"
operators.add(IS);
operators.add(NOT);
PARSER = new RSQLParser(operators);
}
public static Node parse(final String rsql) {
return parse(rsql, (Function<String, Key>) null);
}
public static <T extends Enum<T> & RsqlQueryField> Node parse(final String rsql, final Class<T> rsqlQueryFieldType) {
return parse(rsql, key -> resolveKey(key, rsqlQueryFieldType));
}
private static Node parse(final String rsql, final Function<String, Key> keyResolver) {
try {
return PARSER
.parse(RsqlConfigHolder.getInstance().isCaseInsensitiveDB() || RsqlConfigHolder.getInstance().isIgnoreCase()
? rsql.toLowerCase()
: rsql)
.accept(new RsqlVisitor(keyResolver));
} catch (final RSQLParserException e) {
throw new RSQLParameterSyntaxException(e);
}
}
private static <T extends Enum<T> & RsqlQueryField> Key resolveKey(final String key, final Class<T> rsqlQueryFieldType) {
final int firstSeparatorIndex = key.indexOf(SUB_ATTRIBUTE_SEPARATOR);
final String enumName = (firstSeparatorIndex == -1 ? key : key.substring(0, firstSeparatorIndex)).toUpperCase();
log.debug("Get field identifier by name {} of enum type {}", enumName, rsqlQueryFieldType);
final T enumValue;
try {
enumValue = Enum.valueOf(rsqlQueryFieldType, enumName);
} catch (final IllegalArgumentException e) {
throw new RSQLParameterUnsupportedFieldException(e);
}
final String attribute;
if (firstSeparatorIndex == -1) { // just field name without sub-attribute
if (enumValue.getSubEntityAttributes().isEmpty()) {
// no sub-attributes -> simple field
attribute = enumValue.getJpaEntityFieldName();
} else {
// just enum name for a complex type (with sub-attributes), should have single (default!) sub-attribute
if (enumValue.isMap()) {
throw new RSQLParameterUnsupportedFieldException("No key specified for a map type " + enumValue);
} else if (enumValue.getSubEntityAttributes().size() == 1) {
// single sub attribute - so, treat it as a default
attribute = enumValue.getJpaEntityFieldName() + SUB_ATTRIBUTE_SEPARATOR + enumValue.getSubEntityAttributes().get(0);
} else {
throw new RSQLParameterUnsupportedFieldException(
String.format(
"The given search parameter field {%s} requires one of the following sub-attributes %s",
key, enumValue.getSubEntityAttributes()));
}
}
} else { // field name with sub-attribute
final String subAttribute = key.substring(firstSeparatorIndex + 1);
if (enumValue.isMap()) {
// map, the part after the enum name is the key of the map
attribute = enumValue.getJpaEntityFieldName() + SUB_ATTRIBUTE_SEPARATOR + subAttribute;
} else if (enumValue.getSubEntityAttributes().isEmpty()) {
// simple type without sub-attributes, so the sub-attribute is not allowed
throw new RSQLParameterUnsupportedFieldException("Sub-attributes not supported for simple field " + enumValue);
} else {
attribute = enumValue.getJpaEntityFieldName() + SUB_ATTRIBUTE_SEPARATOR + enumValue.getSubEntityAttributes().stream()
.filter(attr -> attr.equalsIgnoreCase(subAttribute)) // case normalized
.findFirst()
.orElseThrow(() -> new RSQLParameterUnsupportedFieldException(
String.format(
"The given search field {%s} has unsupported sub-attributes. Supported sub-attributes are %s",
key, enumValue.getSubEntityAttributes())));
}
}
return new Key(attribute, RsqlVisitor.valueConverter(enumValue));
}
private record Key(String path, UnaryOperator<Object> converter) {}
private static class RsqlVisitor implements RSQLVisitor<Node, String> {
private final Function<String, Key> keyResolver;
private RsqlVisitor(final Function<String, Key> keyResolver) {
this.keyResolver = keyResolver == null ? str -> new Key(str, UnaryOperator.identity()) : keyResolver;
}
@Override
public Node visit(final AndNode node, final String param) {
return node.getChildren().stream()
.map(child -> child.accept(this))
.reduce(Node::and)
.orElseThrow();
}
@Override
public Node visit(final OrNode node, final String param) {
return node.getChildren().stream()
.map(child -> child.accept(this))
.reduce(Node::or)
.orElseThrow();
}
@Override
public Node visit(final ComparisonNode node, final String param) {
final String nodeSelector = node.getSelector();
final Key key = keyResolver.apply(nodeSelector);
final String path = key.path();
final Object value = toValue(node, key);
final ComparisonOperator op = node.getOperator();
if (op == IS || op == RSQLOperators.EQUAL) {
if ("".equals(value)) {
// keep special backward compatible behaviour tags == '' means "null / has not or ''
return new Comparison(path, EQ, null, nodeSelector).or(new Comparison(path, EQ, "", nodeSelector));
}
return new Comparison(path, isLike(value) ? LIKE : EQ, value, nodeSelector);
} else if (op == NOT || op == RSQLOperators.NOT_EQUAL) {
if ("".equals(value)) {
// keep special backward compatible behaviour. != '' means "not null / has and not ''
return new Comparison(path, LIKE, "*", nodeSelector).and(new Comparison(path, NE, "", nodeSelector));
}
return new Comparison(path, isLike(value) ? NOT_LIKE : NE, value, nodeSelector);
} else if (op == RSQLOperators.GREATER_THAN) {
return new Comparison(path, GT, value, nodeSelector);
} else if (op == RSQLOperators.GREATER_THAN_OR_EQUAL) {
return new Comparison(path, GTE, value, nodeSelector);
} else if (op == RSQLOperators.LESS_THAN) {
return new Comparison(path, LT, value, nodeSelector);
} else if (op == RSQLOperators.LESS_THAN_OR_EQUAL) {
return new Comparison(path, LTE, value, nodeSelector);
} else if (op == RSQLOperators.IN) {
return new Comparison(path, IN, value, nodeSelector);
} else if (op == RSQLOperators.NOT_IN) {
return new Comparison(path, NOT_IN, value, nodeSelector);
} else {
throw new IllegalArgumentException("Unsupported operator: " + node.getOperator());
}
}
private Object toValue(final ComparisonNode node, final Key key) {
final List<String> arguments = node.getArguments();
final ComparisonOperator operator = node.getOperator();
if (arguments.isEmpty()) {
throw new IllegalArgumentException("Operator " + operator + " requires at least one argument");
}
if (arguments.size() == 1 && "null".equals(arguments.get(0)) && (operator == IS || operator == NOT)) {
return null;
} else {
if (operator == RSQLOperators.IN || operator == RSQLOperators.NOT_IN) {
return arguments.stream().map(key.converter()).toList();
} else if (arguments.size() > 1) {
throw new IllegalArgumentException(
"Operator " + operator + " requires exactly one argument, but got: " + arguments.size());
} else {
return key.converter().apply(arguments.get(0));
}
}
}
@SuppressWarnings({ "rawtypes", "unchecked" })
private static <T extends Enum<T>> UnaryOperator<Object> valueConverter(@Nullable final T enumValue) {
if (enumValue instanceof FieldValueConverter fieldValueConverter) {
return value -> {
if (value instanceof String strValue) {
try {
return fieldValueConverter.convertValue(enumValue, strValue);
} catch (final Exception e) {
throw new RSQLParameterUnsupportedFieldException(e.getMessage(), null);
}
} else {
return value;
}
};
} else {
return UnaryOperator.identity();
}
}
private static final String ESCAPE_CHAR_WITH_ASTERISK = "\\" + LIKE_WILDCARD;
private static boolean isLike(final Object value) {
if (value instanceof String valueStr) {
if (valueStr.contains(ESCAPE_CHAR_WITH_ASTERISK)) {
return valueStr.replace(ESCAPE_CHAR_WITH_ASTERISK, "$").indexOf(LIKE_WILDCARD) != -1;
} else {
return valueStr.indexOf(LIKE_WILDCARD) != -1;
}
} else {
return false;
}
}
}
}

View File

@@ -0,0 +1,461 @@
/**
* Copyright (c) 2025 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.rsql;
import static org.eclipse.hawkbit.repository.jpa.rsql.Node.Comparison.Operator.GT;
import static org.eclipse.hawkbit.repository.jpa.rsql.Node.Comparison.Operator.GTE;
import static org.eclipse.hawkbit.repository.jpa.rsql.Node.Comparison.Operator.IN;
import static org.eclipse.hawkbit.repository.jpa.rsql.Node.Comparison.Operator.LIKE;
import static org.eclipse.hawkbit.repository.jpa.rsql.Node.Comparison.Operator.LT;
import static org.eclipse.hawkbit.repository.jpa.rsql.Node.Comparison.Operator.LTE;
import static org.eclipse.hawkbit.repository.jpa.rsql.Node.Comparison.Operator.NE;
import static org.eclipse.hawkbit.repository.jpa.rsql.Node.Comparison.Operator.NOT_IN;
import static org.eclipse.hawkbit.repository.jpa.rsql.Node.Comparison.Operator.NOT_LIKE;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import jakarta.annotation.Nonnull;
import jakarta.persistence.criteria.CriteriaBuilder;
import jakarta.persistence.criteria.CriteriaQuery;
import jakarta.persistence.criteria.Expression;
import jakarta.persistence.criteria.JoinType;
import jakarta.persistence.criteria.MapJoin;
import jakarta.persistence.criteria.Path;
import jakarta.persistence.criteria.Predicate;
import jakarta.persistence.criteria.Root;
import jakarta.persistence.criteria.Subquery;
import jakarta.persistence.metamodel.Attribute;
import jakarta.persistence.metamodel.EntityType;
import jakarta.persistence.metamodel.MapAttribute;
import jakarta.persistence.metamodel.PluralAttribute;
import jakarta.persistence.metamodel.SetAttribute;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.hawkbit.repository.exception.RSQLParameterSyntaxException;
import org.eclipse.hawkbit.repository.exception.RSQLParameterUnsupportedFieldException;
import org.eclipse.hawkbit.repository.jpa.rsql.Node.Comparison;
import org.eclipse.hawkbit.repository.jpa.rsql.Node.Comparison.Operator;
import org.eclipse.hawkbit.repository.jpa.rsql.Node.Logical;
import org.eclipse.hawkbit.repository.rsql.VirtualPropertyReplacer;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.orm.jpa.vendor.Database;
import org.springframework.util.ObjectUtils;
@Slf4j
public class SpecificationBuilder<T> {
private static final char ESCAPE_CHAR = '\\';
private final VirtualPropertyReplacer virtualPropertyReplacer;
private final boolean ensureIgnoreCase;
private final Database database;
public SpecificationBuilder(
final VirtualPropertyReplacer virtualPropertyReplacer,
final boolean ensureIgnoreCase, final Database database) {
this.virtualPropertyReplacer = virtualPropertyReplacer;
this.ensureIgnoreCase = ensureIgnoreCase;
this.database = database;
}
public Specification<T> specification(final Node node) {
return (root, query, cb) -> {
Objects.requireNonNull(query).distinct(true);
return new PredicateBuilder(root, query, cb).build(node);
};
}
private class PredicateBuilder {
private static final char LIKE_WILDCARD = '*';
private static final String LIKE_WILDCARD_STR = String.valueOf(LIKE_WILDCARD);
private static final String ESCAPE_CHAR_WITH_ASTERISK = ESCAPE_CHAR + LIKE_WILDCARD_STR;
private static final Predicate[] PREDICATES_ARRAY_0 = new Predicate[0];
private static final List<Object> NULL_VALUE;
static {
final List<Object> nullList = new ArrayList<>(1);
nullList.add(null);
NULL_VALUE = Collections.unmodifiableList(nullList);
}
private final Root<T> root;
private final CriteriaQuery<?> query;
private final CriteriaBuilder cb;
private final PathResolver pathResolver = new PathResolver();
private PredicateBuilder(final Root<T> root, final CriteriaQuery<?> query, final CriteriaBuilder cb) {
this.root = root;
this.query = query;
this.cb = cb;
}
public Predicate build(final Node node) {
if (node instanceof Comparison comparison) {
return predicate(comparison);
} else if (node instanceof Logical logical) {
final Logical.Operator op = Objects.requireNonNull(logical.getOp());
if (op == Logical.Operator.AND) {
return cb.and(logical.getChildren().stream()
.map(this::build)
.toList()
.toArray(PREDICATES_ARRAY_0));
} else if (op == Logical.Operator.OR) {
return cb.or(logical.getChildren().stream()
.map(child -> {
pathResolver.reset();
return build(child); // for or path resolver joins could be reused
})
.toList()
.toArray(PREDICATES_ARRAY_0));
} else {
throw new IllegalArgumentException("Unsupported logical operator: " + op);
}
} else {
throw new IllegalArgumentException("Unsupported node type: " + node.getClass());
}
}
@SuppressWarnings({ "unchecked", "java:S3776" }) // java:S3776 - easier to read at one place
private Predicate predicate(final Comparison comparison) {
final String[] split = comparison.getKey().split("\\.", 2); // { attributeName [, sub attribute / map key]
final Attribute<? super T, ?> attribute = root.getModel().getAttribute(split[0]);
final Operator op = comparison.getOp();
if (attribute instanceof MapAttribute<?, ?, ?>) {
if (split.length < 2 || ObjectUtils.isEmpty(split[1])) {
throw new RSQLParameterUnsupportedFieldException(
String.format("No key for the map field found. Syntax is: %s.<key name>", getPathContext(comparison)));
}
if (comparison.getValue() == null) {
// map entry with key is null (doesn't exist) / is not null (exists) - use left join with on
return switch (op) {
case EQ -> cb.isNull(pathResolver.getJoinOn(attribute, split[1]));
case NE -> cb.isNotNull(pathResolver.getJoinOn(attribute, split[1]));
default -> throw new RSQLParameterSyntaxException(
String.format("Operator %s is not supported for map fields with value null", op));
};
} else {
final MapJoin<?, ?, ?> mapPath = (MapJoin<?, ?, ?>) pathResolver.getPath(attribute);
final Path<String> valuePath = (Path<String>) mapPath.value();
return cb.and(
equal(mapPath.key(), split[1]),
isNot(op)
? notEqualInLike(comparison, (PluralAttribute<?, ?, ?>) attribute, null)
: compare(comparison, valuePath));
}
} else if (attribute instanceof SetAttribute<?, ?> setAttribute) {
if (split.length < 2 || ObjectUtils.isEmpty(split[1])) {
throw new RSQLParameterUnsupportedFieldException(
String.format("No mapping key for a set field found. Syntax is: %s.<ref name>", getPathContext(comparison)));
}
if (isNot(op)) {
if (op == NOT_LIKE && LIKE_WILDCARD_STR.equals(comparison.getValue())) {
// optimized (?) has non-null/empty attribute - do or with on instead of subquery
return cb.and(cb.isNull(pathResolver.getPath(attribute).get(split[1])));
}
return notEqualInLike(comparison, setAttribute, split[1]);
} else {
if (op == LIKE && LIKE_WILDCARD_STR.equals(comparison.getValue())) {
// optimized (?) has non-null/empty attribute - do or with on instead of subquery
return cb.and(cb.isNotNull(pathResolver.getPath(attribute).get(split[1])));
}
return compare(comparison, pathResolver.getPath(attribute).get(split[1]));
}
} else { // singular attribute (BASIC and EMBEDDABLE) or plural (ListAttribute of entities)
final Path<?> attributePath = pathResolver.getPath(attribute);
return compare(comparison, split.length > 1 ? attributePath.get(split[1]) : attributePath);
}
}
private Predicate compare(final Comparison comparison, final Path<?> fieldPath) {
final List<Object> values = getValues(comparison, fieldPath.getJavaType());
final Object firstValue = values.get(0);
return switch (comparison.getOp()) {
case EQ -> firstValue == null ? cb.isNull(fieldPath) : equal(fieldPath, firstValue);
case NE -> firstValue == null ? cb.isNotNull(fieldPath) : cb.or(cb.isNull(fieldPath), notEqual(fieldPath, firstValue));
case GT -> cb.greaterThan(stringPath(fieldPath), String.valueOf(firstValue)); // JPA handles numbers
case GTE -> cb.greaterThanOrEqualTo(stringPath(fieldPath), String.valueOf(firstValue));
case LT -> cb.lessThan(stringPath(fieldPath), String.valueOf(firstValue));
case LTE -> cb.lessThanOrEqualTo(stringPath(fieldPath), String.valueOf(firstValue));
case IN -> in(stringPath(fieldPath), values);
case NOT_IN -> cb.or(cb.isNull(fieldPath), cb.not(in(stringPath(fieldPath), values)));
case LIKE -> like(stringPath(fieldPath), toSqlLikeValue(String.valueOf(firstValue)));
case NOT_LIKE -> cb.or(cb.isNull(fieldPath), notLike(stringPath(fieldPath), toSqlLikeValue(String.valueOf(firstValue))));
};
}
@SuppressWarnings({ "unchecked", "rawtypes" })
private Predicate notEqualInLike( // NE, NOT_IN, NOT_LIKE
final Comparison comparison, final PluralAttribute<?, ?, ?> pluralAttribute, final String subAttributeName) {
final List<Object> values = getValues(comparison, pluralAttribute.getElementType().getJavaType());
final Object value = values.get(0);
final EntityType<T> model = root.getModel();
if (!model.hasSingleIdAttribute()) {
throw new IllegalStateException("Root entity has no single id attribute");
}
final String idAttributeName = model.getId(model.getIdType().getJavaType()).getName();
final Class<? extends T> javaType = root.getJavaType();
final Subquery<? extends T> subquery = query.subquery(javaType);
final Root<? extends T> subqueryRoot = subquery.from(javaType);
final Path<?> joinPath = subAttributeName == null // if null it is a map
? ((MapJoin<?, ?, ?>) subqueryRoot.join(pluralAttribute.getName(), JoinType.LEFT)).value()
: subqueryRoot.join(pluralAttribute.getName(), JoinType.LEFT).get(subAttributeName);
final Path<String> fieldPath = joinPath instanceof MapJoin<?, ?, ?> mapJoin
? (Path<String>) mapJoin.value()
: stringPath(joinPath);
return cb.not(cb.exists(
subquery.select((Expression) subqueryRoot)
.where(cb.and(
cb.equal(root.get(idAttributeName), subqueryRoot.get(idAttributeName)),
switch (comparison.getOp()) {
case NE -> equal(fieldPath, value);
case NOT_IN -> in(fieldPath, values);
case NOT_LIKE -> like(fieldPath, toSqlLikeValue((String) value));
default -> throw new IllegalStateException("Uncovered flow. Operator: " + comparison.getOp());
}))));
}
// java:S1066 - easier to understand separately
// java:S3776, java:S3358 - easier to read at one place
@SuppressWarnings({ "java:S1066", "java:S3776", "java:S3358" })
private List<Object> getValues(final Comparison comparison, final Class<?> javaType) {
final Object value = comparison.getValue();
final List<Object> values = (value == null ? NULL_VALUE : (value instanceof List<?> list ? list : List.of(value))).stream()
// if lookup is available, replace macros ...
.map(element -> virtualPropertyReplacer != null && element instanceof String strElement
? virtualPropertyReplacer.replace(strElement)
: element)
// converts value to the correct type
.map(element -> element instanceof String strElement
? convertValueIfNecessary(strElement, javaType, comparison)
: element)
.toList();
if (values.isEmpty()) {
throw new RSQLParameterSyntaxException("RSQL values must not be empty", null);
} else if (values.size() == 1) {
final Operator op = comparison.getOp();
// enum, boolean or null - doesn't support >, >=, <, <=
if (!(values.get(0) instanceof String)) {
if (values.get(0) instanceof Number) {
if (op == LIKE || op == NOT_LIKE) {
throw new RSQLParameterSyntaxException(op + " operator could not be applied number", null);
}
} else if (op == GT || op == GTE || op == LT || op == LTE || op == LIKE || op == NOT_LIKE) {
final String errorMsg = values.get(0) == null ? "null value" : "enum or boolean field";
throw new RSQLParameterSyntaxException(op + " operator could not be applied to " + errorMsg, null);
}
}
} else {
final Operator op = comparison.getOp();
if (op != IN && op != NOT_IN) {
throw new RSQLParameterSyntaxException(op + " operator shall have exactly one value", null);
}
}
return values;
}
// result is String, enum value, boolean or null
private Object convertValueIfNecessary(final String value, final Class<?> javaType, final Comparison comparison) {
if (javaType != null && javaType.isEnum()) {
return toEnumValue(value, javaType, comparison);
}
if (boolean.class.equals(javaType) || Boolean.class.equals(javaType)) {
if ("true".equals(value) || "false".equals(value)) {
return Boolean.valueOf(value);
} else {
throw new RSQLParameterSyntaxException(
String.format(
"The value of %S is not well formed. Only a boolean (true or false) value will be expected",
getPathContext(comparison)));
}
}
return value;
}
@Nonnull
private static Object getPathContext(final Comparison comparison) {
return comparison.getContext() == null ? comparison.getKey() : comparison.getContext();
}
private static boolean isNot(final Operator op) {
return op == NE || op == NOT_IN || op == NOT_LIKE;
}
@SuppressWarnings({ "rawtypes", "unchecked" })
private static Object toEnumValue(final String value, final Class<?> javaType, final Comparison comparison) {
final Class<? extends Enum> tmpEnumType = (Class<? extends Enum>) javaType;
try {
return Enum.valueOf(tmpEnumType, value.toUpperCase());
} catch (final IllegalArgumentException e) {
// we could not transform the given string value into the enum type, so ignore it and return null and do not filter
if (log.isInfoEnabled()) {
log.info("Provided value '{}' cannot be transformed into the correct enum type {}", value.toUpperCase(), javaType);
} else {
log.debug("Provided value '{}' cannot be transformed into the correct enum type {}", value.toUpperCase(), javaType, e);
}
throw new RSQLParameterUnsupportedFieldException(
String.format(
"Value of %s must be one of the following: %s",
getPathContext(comparison),
Arrays.stream(tmpEnumType.getEnumConstants())
.map(Enum::name)
.map(String::toLowerCase)
.toList()),
e);
}
}
@SuppressWarnings("unchecked")
private static Path<String> stringPath(final Path<?> path) {
return (Path<String>) path;
}
private String toSqlLikeValue(final String value) {
final String escaped;
if (database == Database.SQL_SERVER) {
escaped = value.replace("%", "[%]").replace("_", "[_]");
} else {
escaped = value.replace("%", ESCAPE_CHAR + "%").replace("_", ESCAPE_CHAR + "_");
}
final String finalizedValue;
if (escaped.contains(ESCAPE_CHAR_WITH_ASTERISK)) {
finalizedValue = escaped.replace(ESCAPE_CHAR_WITH_ASTERISK, "$")
.replace(LIKE_WILDCARD, '%')
.replace("$", ESCAPE_CHAR_WITH_ASTERISK);
} else {
finalizedValue = escaped.replace(LIKE_WILDCARD, '%');
}
return finalizedValue;
}
@SuppressWarnings("java:S1221") // java:S1221 - intentionally to match the SQL wording
private Predicate equal(final Path<?> fieldPath, final Object value) {
if (value instanceof String valueStr && caseWise(fieldPath)) {
return cb.equal(cb.upper(stringPath(fieldPath)), valueStr.toUpperCase());
} else {
return cb.equal(fieldPath, value);
}
}
private Predicate notEqual(final Path<?> fieldPath, Object value) {
if (value instanceof String valueStr && caseWise(fieldPath)) {
return cb.notEqual(cb.upper(stringPath(fieldPath)), valueStr.toUpperCase());
} else {
return cb.notEqual(fieldPath, value);
}
}
private Predicate like(final Path<String> fieldPath, final String sqlValue) {
if (caseWise(fieldPath)) {
return cb.like(cb.upper(fieldPath), sqlValue.toUpperCase(), ESCAPE_CHAR);
} else {
return cb.like(fieldPath, sqlValue, ESCAPE_CHAR);
}
}
private Predicate notLike(final Path<String> fieldPath, final String sqlValue) {
if (caseWise(fieldPath)) {
return cb.notLike(cb.upper(fieldPath), sqlValue.toUpperCase(), ESCAPE_CHAR);
} else {
return cb.notLike(fieldPath, sqlValue, ESCAPE_CHAR);
}
}
private Predicate in(final Path<String> fieldPath, final List<Object> values) {
if (caseWise(fieldPath)) {
final List<String> inParams = values.stream()
.filter(String.class::isInstance)
.map(String.class::cast).map(String::toUpperCase).toList();
return inParams.isEmpty() ? fieldPath.in(values) : cb.upper(fieldPath).in(inParams);
} else {
return fieldPath.in(values);
}
}
private boolean caseWise(final Path<?> fieldPath) {
return ensureIgnoreCase && fieldPath.getJavaType() == String.class;
}
private class PathResolver {
private final Map<String, CollectionPathResolver> attributeToPathResolver = new HashMap<>();
private Path<?> getPath(final Attribute<? super T, ?> attribute) {
return switch (attribute.getPersistentAttributeType()) {
case BASIC -> root.get(attribute.getName());
case MANY_TO_ONE, ONE_TO_ONE -> root.getJoins().stream()
.filter(join -> join.getAttribute().equals(attribute))
.findFirst()
.orElseGet(() -> root.join(attribute.getName(), JoinType.LEFT));
case MANY_TO_MANY, ONE_TO_MANY, ELEMENT_COLLECTION -> getCollectionPathResolver(attribute.getName()).getPath();
default -> throw new IllegalArgumentException("Unsupported attribute type: " + attribute.getPersistentAttributeType());
};
}
private MapJoin<?, ?, ?> getJoinOn(final Attribute<?, ?> attribute, final Object value) {
return getCollectionPathResolver(attribute.getName()).getJoinOn(value);
}
private void reset() {
attributeToPathResolver.values().forEach(CollectionPathResolver::reset);
}
@Nonnull
private CollectionPathResolver getCollectionPathResolver(final String attributeName) {
return attributeToPathResolver.computeIfAbsent(attributeName, CollectionPathResolver::new);
}
private class CollectionPathResolver {
private final String attributeName;
private final List<Path<?>> paths = new ArrayList<>();
private int pos;
private final Map<Object, MapJoin<?, ?, ?>> joinOnCache = new HashMap<>();
private CollectionPathResolver(final String attributeName) {
this.attributeName = attributeName;
}
private Path<?> getPath() {
if (pos < paths.size()) {
return paths.get(pos++);
} else {
final Path<?> path = root.join(attributeName, JoinType.LEFT);
paths.add(path);
pos++;
return path;
}
}
private MapJoin<?, ?, ?> getJoinOn(final Object value) {
return joinOnCache.computeIfAbsent(value, k -> {
final MapJoin<?, ?, ?> mapPath = (MapJoin<?, ?, ?>) root.join(attributeName, JoinType.LEFT);
mapPath.on(equal(mapPath.key(), k));
return mapPath;
});
}
private void reset() {
pos = 0;
}
}
}
}
}

View File

@@ -1,5 +1,5 @@
/**
* Copyright (c) 2020 devolo AG and others
* Copyright (c) 2025 Contributors to the Eclipse Foundation
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
@@ -7,15 +7,20 @@
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.repository.jpa.rsql;
package org.eclipse.hawkbit.repository.jpa.rsqllegacy;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Stream;
import jakarta.validation.constraints.NotNull;
import cz.jirutka.rsql.parser.ast.ComparisonNode;
import cz.jirutka.rsql.parser.ast.ComparisonOperator;
import cz.jirutka.rsql.parser.ast.RSQLOperators;
import lombok.Value;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.hawkbit.repository.RsqlQueryField;
@@ -25,6 +30,19 @@ import org.springframework.util.ObjectUtils;
@Slf4j
public abstract class AbstractRSQLVisitor<A extends Enum<A> & RsqlQueryField> {
static final ComparisonOperator IS = new ComparisonOperator("=is=", "=eq=");
static final ComparisonOperator NOT = new ComparisonOperator("=not=", "=ne=");
static final Set<ComparisonOperator> OPERATORS;
static {
final Set<ComparisonOperator> operators = new HashSet<>(RSQLOperators.defaultOperators());
// == and != alternatives just treating "null" string as null not as a "null"
operators.add(IS);
operators.add(NOT);
OPERATORS = Collections.unmodifiableSet(operators);
}
private final Class<A> rsqlQueryFieldType;
protected AbstractRSQLVisitor(final Class<A> rsqlQueryFieldType) {
@@ -86,8 +104,6 @@ public abstract class AbstractRSQLVisitor<A extends Enum<A> & RsqlQueryField> {
}
}
/**
* Contains the sub entity the given field.
*

View File

@@ -7,7 +7,7 @@
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.repository.jpa.rsql;
package org.eclipse.hawkbit.repository.jpa.rsqllegacy;
import java.util.ArrayList;
import java.util.Arrays;

View File

@@ -1,5 +1,5 @@
/**
* Copyright (c) 2021 Bosch.IO GmbH and others
* Copyright (c) 2025 Contributors to the Eclipse Foundation
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
@@ -7,7 +7,7 @@
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.repository.jpa.rsql;
package org.eclipse.hawkbit.repository.jpa.rsqllegacy;
import java.util.ArrayList;
import java.util.Arrays;
@@ -54,7 +54,12 @@ import org.springframework.util.ObjectUtils;
*
* @param <A> the enum for providing the field name of the entity field to filter on.
* @param <T> the entity type referenced by the root
* @deprecated Old implementation of RSQL Visitor (G2). Deprecated in favour of next gen implementation -
* {@link org.eclipse.hawkbit.repository.jpa.rsql.SpecificationBuilder}.
* It will be kept for some time in order to keep backward compatibility and to allow for a smooth transition. Also, in case of
* problems with the new implementation, this one can be used as a fallback.
*/
@Deprecated(forRemoval = true, since = "0.9.0")
@Slf4j
public class JpaQueryRsqlVisitorG2<A extends Enum<A> & RsqlQueryField, T>
extends AbstractRSQLVisitor<A> implements RSQLVisitor<List<Predicate>, String> {
@@ -115,19 +120,19 @@ public class JpaQueryRsqlVisitorG2<A extends Enum<A> & RsqlQueryField, T>
private Predicate toPredicate(final ComparisonNode node, final QueryPath queryPath, final Path<?> fieldPath, final List<Object> values) {
final Predicate mapEntryKeyPredicate;
if (queryPath.getEnumValue().isMap()) {
if (node.getOperator() == RSQLUtility.IS) {
if (node.getOperator() == IS) {
// special handling of "not-exists"
if (values.size() != 1) {
throw new RSQLParameterSyntaxException("The operator '" + RSQLUtility.IS + "' can only be used with one value");
throw new RSQLParameterSyntaxException("The operator '" + IS + "' can only be used with one value");
}
if (values.get(0) == null) {
// IS operator for maps and null value is treated as doesn't exist correspondingly
((PluralJoin<?, ?, ?>) fieldPath).on(toMapEntryKeyPredicate(queryPath, fieldPath));
return cb.isNull(fieldPath);
}
} else if (node.getOperator() == RSQLUtility.NOT) {
} else if (node.getOperator() == NOT) {
if (values.size() != 1) {
throw new RSQLParameterSyntaxException("The operator '" + RSQLUtility.NOT + "' can only be used with one value");
throw new RSQLParameterSyntaxException("The operator '" + NOT + "' can only be used with one value");
}
// NOT operator for maps and null value is treated as does exist correspondingly
((PluralJoin<?, ?, ?>) fieldPath).on(toMapEntryKeyPredicate(queryPath, fieldPath));
@@ -371,7 +376,7 @@ public class JpaQueryRsqlVisitorG2<A extends Enum<A> & RsqlQueryField, T>
if ("null".equals(value)) {
final ComparisonOperator operator = node.getOperator();
if (operator == RSQLUtility.IS || operator == RSQLUtility.NOT) {
if (operator == IS || operator == NOT) {
return null;
}
}

View File

@@ -0,0 +1,84 @@
/**
* Copyright (c) 2025 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.rsqllegacy;
import static org.eclipse.hawkbit.repository.rsql.RsqlConfigHolder.RsqlToSpecBuilder.LEGACY_G1;
import java.util.List;
import jakarta.persistence.criteria.Predicate;
import cz.jirutka.rsql.parser.RSQLParser;
import cz.jirutka.rsql.parser.RSQLParserException;
import cz.jirutka.rsql.parser.ast.Node;
import cz.jirutka.rsql.parser.ast.RSQLVisitor;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.hawkbit.repository.RsqlQueryField;
import org.eclipse.hawkbit.repository.exception.RSQLParameterSyntaxException;
import org.eclipse.hawkbit.repository.rsql.RsqlConfigHolder;
import org.eclipse.hawkbit.repository.rsql.VirtualPropertyReplacer;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.orm.jpa.vendor.Database;
import org.springframework.util.CollectionUtils;
@Slf4j
public class SpecificationBuilderLegacy<A extends Enum<A> & RsqlQueryField, T> {
private final Class<A> rsqlQueryFieldType;
private final VirtualPropertyReplacer virtualPropertyReplacer;
private final Database database;
public SpecificationBuilderLegacy(
final Class<A> rsqlQueryFieldType, final VirtualPropertyReplacer virtualPropertyReplacer, final Database database) {
this.rsqlQueryFieldType = rsqlQueryFieldType;
this.virtualPropertyReplacer = virtualPropertyReplacer;
this.database = database;
}
public Specification<T> specification(final String rsql) {
return (root, query, cb) -> {
final Node rootNode = parseRsql(rsql);
query.distinct(true);
final RSQLVisitor<List<Predicate>, String> jpqQueryRSQLVisitor =
RsqlConfigHolder.getInstance().getRsqlToSpecBuilder() == LEGACY_G1
? new JpaQueryRsqlVisitor<>(
root, cb, rsqlQueryFieldType,
virtualPropertyReplacer, database, query,
!RsqlConfigHolder.getInstance().isCaseInsensitiveDB() && RsqlConfigHolder.getInstance().isIgnoreCase())
: new JpaQueryRsqlVisitorG2<>(
rsqlQueryFieldType, root, query, cb,
database, virtualPropertyReplacer,
!RsqlConfigHolder.getInstance().isCaseInsensitiveDB() && RsqlConfigHolder.getInstance()
.isIgnoreCase());
final List<Predicate> accept = rootNode.accept(jpqQueryRSQLVisitor);
if (CollectionUtils.isEmpty(accept)) {
return cb.conjunction();
} else {
return cb.and(accept.toArray(new Predicate[0]));
}
};
}
private static Node parseRsql(final String rsql) {
log.debug("Parsing rsql string {}", rsql);
try {
return new RSQLParser(AbstractRSQLVisitor.OPERATORS).parse(
RsqlConfigHolder.getInstance().isCaseInsensitiveDB() || RsqlConfigHolder.getInstance().isIgnoreCase()
? rsql.toLowerCase()
: rsql);
} catch (final IllegalArgumentException e) {
throw new RSQLParameterSyntaxException("RSQL filter must not be null", e);
} catch (final RSQLParserException e) {
throw new RSQLParameterSyntaxException(e);
}
}
}

View File

@@ -12,6 +12,7 @@ package org.eclipse.hawkbit.repository.jpa.management;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.assertj.core.api.Assertions.fail;
import static org.eclipse.hawkbit.repository.rsql.RsqlConfigHolder.RsqlToSpecBuilder.LEGACY_G1;
import java.net.URI;
import java.util.ArrayList;
@@ -1030,7 +1031,7 @@ class TargetManagementTest extends AbstractJpaIntegrationTest {
@Test
@Description("Test that RSQL filter finds targets with tag and metadata.")
void findTargetsByRsqlWithTypeAndMetadata() {
if (RsqlConfigHolder.getInstance().isLegacyRsqlVisitor()) {
if (RsqlConfigHolder.getInstance().getRsqlToSpecBuilder() == LEGACY_G1) {
// legacy visitor fail with that
return;
}

View File

@@ -0,0 +1,38 @@
/**
* Copyright (c) 2025 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.rsql;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
class NodeTest {
@Test
void testSimplify() {
final Node aEqB = new Node.Comparison("a", Node.Comparison.Operator.EQ, "b");
final Node aEqC = new Node.Comparison("a", Node.Comparison.Operator.EQ, "c");
final Node aEqD = new Node.Comparison("a", Node.Comparison.Operator.EQ, "d");
final Node aEqE = new Node.Comparison("a", Node.Comparison.Operator.EQ, "e");
assertThat(aEqB.and(aEqC).and(aEqD).and(aEqE).getChildren())
.hasSize(4)
.containsExactly(aEqB, aEqC, aEqD, aEqE);
assertThat(aEqB.and(aEqC).and(aEqD.and(aEqE)).getChildren())
.hasSize(4)
.containsExactly(aEqB, aEqC, aEqD, aEqE);
assertThat(aEqB.or(aEqC).or(aEqD).or(aEqE).getChildren())
.hasSize(4)
.containsExactly(aEqB, aEqC, aEqD, aEqE);
assertThat(aEqB.or(aEqC).or(aEqD.or(aEqE)).getChildren())
.hasSize(4)
.containsExactly(aEqB, aEqC, aEqD, aEqE);
}
}

View File

@@ -11,6 +11,8 @@ package org.eclipse.hawkbit.repository.jpa.rsql;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.eclipse.hawkbit.repository.rsql.RsqlConfigHolder.RsqlToSpecBuilder.LEGACY_G2;
import static org.eclipse.hawkbit.repository.rsql.RsqlConfigHolder.RsqlToSpecBuilder.LEGACY_G1;
import java.util.Arrays;
import java.util.Map;
@@ -29,6 +31,7 @@ import org.eclipse.hawkbit.repository.model.DistributionSet;
import org.eclipse.hawkbit.repository.model.Target;
import org.eclipse.hawkbit.repository.model.TargetTag;
import org.eclipse.hawkbit.repository.model.TargetType;
import org.eclipse.hawkbit.repository.rsql.RsqlConfigHolder;
import org.eclipse.hawkbit.repository.rsql.VirtualPropertyReplacer;
import org.eclipse.hawkbit.repository.test.util.TestdataFactory;
import org.junit.jupiter.api.BeforeEach;
@@ -176,9 +179,11 @@ class RSQLTargetFieldTest extends AbstractJpaIntegrationTest {
assertRSQLQuery(TargetFields.ATTRIBUTE.name() + ".test.null==null", 1); // "null" check
assertRSQLQuery(TargetFields.ATTRIBUTE.name() + ".test.n/a==null", 0); // "null" check
assertRSQLQuery(TargetFields.ATTRIBUTE.name() + ".test.dot=is=value.dot", 1);
assertRSQLQuery(TargetFields.ATTRIBUTE.name() + ".test.null=is=null", 5); // null check
assertRSQLQuery(TargetFields.ATTRIBUTE.name() + ".test.n/a=is=null", 1 + 5); // null check
if (RsqlConfigHolder.getInstance().getRsqlToSpecBuilder() != LEGACY_G1) {
assertRSQLQuery(TargetFields.ATTRIBUTE.name() + ".test.dot=is=value.dot", 1);
assertRSQLQuery(TargetFields.ATTRIBUTE.name() + ".test.null=is=null", 5); // null check
assertRSQLQuery(TargetFields.ATTRIBUTE.name() + ".test.n/a=is=null", 1 + 5); // null check
}
assertRSQLQuery(TargetFields.ATTRIBUTE.name() + ".test.dot=eq=value.dot", 1);
assertRSQLQuery(TargetFields.ATTRIBUTE.name() + ".test.null=eq=null", 5); // null check
assertRSQLQuery(TargetFields.ATTRIBUTE.name() + ".test.n/a=eq=null", 1 + 5); // null check
@@ -188,14 +193,24 @@ class RSQLTargetFieldTest extends AbstractJpaIntegrationTest {
assertRSQLQuery(TargetFields.ATTRIBUTE.name() + ".test.null!=null2", 1); // value check
assertRSQLQuery(TargetFields.ATTRIBUTE.name() + ".test.n/a!=null", 0); // "null" check
assertRSQLQuery(TargetFields.ATTRIBUTE.name() + ".test.dot=not=value.dot", 5);
assertRSQLQuery(TargetFields.ATTRIBUTE.name() + ".test.null=not=null", 1); // null check
assertRSQLQuery(TargetFields.ATTRIBUTE.name() + ".test.null=not=null2", 1 + 5); // value check
assertRSQLQuery(TargetFields.ATTRIBUTE.name() + ".test.n/a=not=null", 0); // null check
assertRSQLQuery(TargetFields.ATTRIBUTE.name() + ".test.dot=ne=value.dot", 5);
assertRSQLQuery(TargetFields.ATTRIBUTE.name() + ".test.null=ne=null", 1); // null check
assertRSQLQuery(TargetFields.ATTRIBUTE.name() + ".test.null=ne=null2", 1 + 5); // value check
assertRSQLQuery(TargetFields.ATTRIBUTE.name() + ".test.n/a=ne=null", 0); // null check
if (RsqlConfigHolder.getInstance().getRsqlToSpecBuilder() != LEGACY_G1) {
assertRSQLQuery(
TargetFields.ATTRIBUTE.name() + ".test.dot=not=value.dot",
RsqlConfigHolder.getInstance().getRsqlToSpecBuilder() == LEGACY_G2 ? 5 : 0);
assertRSQLQuery(TargetFields.ATTRIBUTE.name() + ".test.null=not=null", 1); // null check
assertRSQLQuery(
TargetFields.ATTRIBUTE.name() + ".test.null=not=null2",
1 + (RsqlConfigHolder.getInstance().getRsqlToSpecBuilder() == LEGACY_G2 ? 5 : 0)); // value check
assertRSQLQuery(TargetFields.ATTRIBUTE.name() + ".test.n/a=not=null", 0); // null check
assertRSQLQuery(
TargetFields.ATTRIBUTE.name() + ".test.dot=ne=value.dot",
RsqlConfigHolder.getInstance().getRsqlToSpecBuilder() == LEGACY_G2 ? 5 : 0);
assertRSQLQuery(TargetFields.ATTRIBUTE.name() + ".test.null=ne=null", 1); // null check
assertRSQLQuery(
TargetFields.ATTRIBUTE.name() + ".test.null=ne=null2",
1 + (RsqlConfigHolder.getInstance().getRsqlToSpecBuilder() == LEGACY_G2 ? 5 : 0)); // value check
assertRSQLQuery(TargetFields.ATTRIBUTE.name() + ".test.n/a=ne=null", 0); // null check
}
assertRSQLQuery(TargetFields.ATTRIBUTE.name() + ".revision==1.1", 1);
assertRSQLQuery(TargetFields.ATTRIBUTE.name() + ".revision!=1.1", 1);
@@ -245,6 +260,8 @@ class RSQLTargetFieldTest extends AbstractJpaIntegrationTest {
assertRSQLQuery(TargetFields.TAG.name() + "!=notexist", 5);
assertRSQLQuery(TargetFields.TAG.name() + "==''", 1);
assertRSQLQuery(TargetFields.TAG.name() + "!=''", 4);
assertRSQLQuery(TargetFields.TAG.name() + "!=*", 1); // has no tags
assertRSQLQuery(TargetFields.TAG.name() + "==*", 4); // has tags
assertRSQLQuery(TargetFields.TAG.name() + "=in=(Tag1,notexist)", 2);
assertRSQLQuery(TargetFields.TAG.name() + "=in=(null)", 0);
assertRSQLQuery(TargetFields.TAG.name() + "=out=(Tag1,notexist)", 3);
@@ -278,11 +295,13 @@ class RSQLTargetFieldTest extends AbstractJpaIntegrationTest {
assertRSQLQuery(TargetFields.METADATA.name() + ".metaKey==noExist*", 0);
assertRSQLQuery(TargetFields.METADATA.name() + ".notExist==metaValue", 0);
assertRSQLQuery(TargetFields.METADATA.name() + ".metaKey=is=metaValue", 1);
assertRSQLQuery(TargetFields.METADATA.name() + ".metaKey=is=null", 4); // null check (1 of the initial five has)
assertRSQLQuery(TargetFields.METADATA.name() + ".metaKey=is=*v*", 2);
assertRSQLQuery(TargetFields.METADATA.name() + ".metaKey=is=noExist*", 0);
assertRSQLQuery(TargetFields.METADATA.name() + ".notExist=is=null", 1 + 5); // null check
if (RsqlConfigHolder.getInstance().getRsqlToSpecBuilder() != LEGACY_G1) {
assertRSQLQuery(TargetFields.METADATA.name() + ".metaKey=is=metaValue", 1);
assertRSQLQuery(TargetFields.METADATA.name() + ".metaKey=is=null", 4); // null check (1 of the initial five has)
assertRSQLQuery(TargetFields.METADATA.name() + ".metaKey=is=*v*", 2);
assertRSQLQuery(TargetFields.METADATA.name() + ".metaKey=is=noExist*", 0);
assertRSQLQuery(TargetFields.METADATA.name() + ".notExist=is=null", 1 + 5); // null check
}
assertRSQLQuery(TargetFields.METADATA.name() + ".metaKey=eq=metaValue", 1);
assertRSQLQuery(TargetFields.METADATA.name() + ".metaKey=eq=null", 4); // null check (1 of the initial five has)
assertRSQLQuery(TargetFields.METADATA.name() + ".metaKey=eq=*v*", 2);
@@ -293,15 +312,29 @@ class RSQLTargetFieldTest extends AbstractJpaIntegrationTest {
assertRSQLQuery(TargetFields.METADATA.name() + ".metaKey!=null", 2);
assertRSQLQuery(TargetFields.METADATA.name() + ".notExist!=metaValue", 0);
assertRSQLQuery(TargetFields.METADATA.name() + ".metaKey!=notExist", 2);
//
assertRSQLQuery(TargetFields.METADATA.name() + ".metaKey=not=metaValue", 1 + 4);
assertRSQLQuery(TargetFields.METADATA.name() + ".metaKey=not=null", 2); // null check (2 of the initial five)
assertRSQLQuery(TargetFields.METADATA.name() + ".notExist=not=metaValue", 1 + 5);
assertRSQLQuery(TargetFields.METADATA.name() + ".metaKey=not=notExist", 1 + 5);
assertRSQLQuery(TargetFields.METADATA.name() + ".metaKey=ne=metaValue", 1 + 4);
assertRSQLQuery(TargetFields.METADATA.name() + ".metaKey=ne=null", 2); // null check (2 of the initial five)
assertRSQLQuery(TargetFields.METADATA.name() + ".notExist=ne=metaValue", 1 + 5);
assertRSQLQuery(TargetFields.METADATA.name() + ".metaKey=ne=notExist", 1 + 5);
if (RsqlConfigHolder.getInstance().getRsqlToSpecBuilder() != LEGACY_G1) {
assertRSQLQuery(
TargetFields.METADATA.name() + ".metaKey=not=metaValue",
RsqlConfigHolder.getInstance().getRsqlToSpecBuilder() == LEGACY_G2 ? 1 + 4 : 1);
assertRSQLQuery(TargetFields.METADATA.name() + ".metaKey=not=null", 2); // null check (2 of the initial five)
assertRSQLQuery(
TargetFields.METADATA.name() + ".notExist=not=metaValue",
RsqlConfigHolder.getInstance().getRsqlToSpecBuilder() == LEGACY_G2 ? 1 + 5 : 0);
assertRSQLQuery(
TargetFields.METADATA.name() + ".metaKey=not=notExist",
RsqlConfigHolder.getInstance().getRsqlToSpecBuilder() == LEGACY_G2 ? 2 + 4 : 2);
assertRSQLQuery(
TargetFields.METADATA.name() + ".metaKey=ne=metaValue",
RsqlConfigHolder.getInstance().getRsqlToSpecBuilder() == LEGACY_G2 ? 1 + 4 : 1);
assertRSQLQuery(TargetFields.METADATA.name() + ".metaKey=ne=null", 2); // null check (2 of the initial five)
assertRSQLQuery(
TargetFields.METADATA.name() + ".notExist=ne=metaValue",
RsqlConfigHolder.getInstance().getRsqlToSpecBuilder() == LEGACY_G2 ? 1 + 5 : 0);
assertRSQLQuery(
TargetFields.METADATA.name() + ".metaKey=ne=notExist",
RsqlConfigHolder.getInstance().getRsqlToSpecBuilder() == LEGACY_G2 ? 2 + 4 : 2);
}
assertRSQLQuery(TargetFields.METADATA.name() + ".metaKey=in=(metaValue,notexist)", 1);
assertRSQLQuery(TargetFields.METADATA.name() + ".metaKey=out=(metaValue,notexist)", 1);

View File

@@ -9,25 +9,16 @@
*/
package org.eclipse.hawkbit.repository.jpa.rsql;
import java.util.List;
import jakarta.persistence.EntityManager;
import jakarta.persistence.TypedQuery;
import jakarta.persistence.criteria.CriteriaBuilder;
import jakarta.persistence.criteria.CriteriaQuery;
import jakarta.persistence.criteria.Predicate;
import jakarta.persistence.criteria.Root;
import cz.jirutka.rsql.parser.RSQLParser;
import cz.jirutka.rsql.parser.ast.Node;
import cz.jirutka.rsql.parser.ast.RSQLOperators;
import cz.jirutka.rsql.parser.ast.RSQLVisitor;
import org.eclipse.hawkbit.repository.RsqlQueryField;
import org.eclipse.hawkbit.repository.jpa.Utils;
import org.eclipse.hawkbit.repository.rsql.RsqlConfigHolder;
import org.eclipse.hawkbit.repository.rsql.VirtualPropertyReplacer;
import org.eclipse.hawkbit.repository.rsql.RsqlConfigHolder.RsqlToSpecBuilder;
import org.springframework.orm.jpa.vendor.Database;
import org.springframework.util.CollectionUtils;
public class RSQLToSQL {
@@ -39,50 +30,27 @@ public class RSQLToSQL {
}
public <T, A extends Enum<A> & RsqlQueryField> String toSQL(
final Class<T> domainClass, final Class<A> fieldsClass, final String rsql, final boolean legacyRsqlVisitor) {
final CriteriaQuery<T> query = createQuery(domainClass, fieldsClass, rsql, legacyRsqlVisitor);
final Class<T> domainClass, final Class<A> fieldsClass, final String rsql, final RsqlToSpecBuilder rsqlToSpecBuilder) {
final CriteriaQuery<T> query = createQuery(domainClass, fieldsClass, rsql, rsqlToSpecBuilder);
final TypedQuery<?> typedQuery = entityManager.createQuery(query);
return Utils.toSql(typedQuery);
}
private <T, A extends Enum<A> & RsqlQueryField> CriteriaQuery<T> createQuery(
final Class<T> domainClass, final Class<A> fieldsClass, final String rsql, final boolean legacyRsqlVisitor) {
final Class<T> domainClass, final Class<A> fieldsClass, final String rsql, final RsqlToSpecBuilder rsqlToSpecBuilder) {
final CriteriaQuery<T> query = entityManager.getCriteriaBuilder().createQuery(domainClass);
final CriteriaBuilder cb = entityManager.getCriteriaBuilder();
return query.where(
RsqlConfigHolder.getInstance().isLegacyRsqlVisitor() == legacyRsqlVisitor ?
// use directly
RSQLUtility.<A, T> buildRsqlSpecification(rsql, fieldsClass, null, DATABASE)
.toPredicate(query.from(domainClass), cb.createQuery(domainClass), cb) :
toPredicate(rsql, fieldsClass, null,
query.from(domainClass), cb.createQuery(domainClass), cb, legacyRsqlVisitor)
);
}
private <T, A extends Enum<A> & RsqlQueryField> Predicate toPredicate(
final String rsql,
final Class<A> fieldsClass, final VirtualPropertyReplacer virtualPropertyReplacer,
final Root<T> root, final CriteriaQuery<?> query, final CriteriaBuilder cb,
final boolean legacyRsqlVisitor) {
final Node rootNode = new RSQLParser(RSQLOperators.defaultOperators()).parse(rsql);
query.distinct(true);
final RSQLVisitor<List<Predicate>, String> jpqQueryRSQLVisitor =
legacyRsqlVisitor
? new JpaQueryRsqlVisitor<>(
root, cb, fieldsClass,
virtualPropertyReplacer, DATABASE, query,
!RsqlConfigHolder.getInstance().isCaseInsensitiveDB() && RsqlConfigHolder.getInstance().isIgnoreCase())
: new JpaQueryRsqlVisitorG2<>(
fieldsClass, root, query, cb,
DATABASE, virtualPropertyReplacer,
!RsqlConfigHolder.getInstance().isCaseInsensitiveDB() && RsqlConfigHolder.getInstance().isIgnoreCase());
final List<Predicate> accept = rootNode.accept(jpqQueryRSQLVisitor);
if (CollectionUtils.isEmpty(accept)) {
return cb.conjunction();
} else {
return cb.and(accept.toArray(new Predicate[0]));
final RsqlToSpecBuilder defaultRsqlToSpecBuilder = RsqlConfigHolder.getInstance().getRsqlToSpecBuilder();
if (defaultRsqlToSpecBuilder != rsqlToSpecBuilder) {
RsqlConfigHolder.getInstance().setRsqlToSpecBuilder(rsqlToSpecBuilder);
}
try {
return query.where(RSQLUtility.<A, T> buildRsqlSpecification(rsql, fieldsClass, null, DATABASE)
.toPredicate(query.from(domainClass), cb.createQuery(domainClass), cb));
} finally {
if (defaultRsqlToSpecBuilder != rsqlToSpecBuilder) {
RsqlConfigHolder.getInstance().setRsqlToSpecBuilder(defaultRsqlToSpecBuilder);
}
}
}
}

View File

@@ -9,6 +9,9 @@
*/
package org.eclipse.hawkbit.repository.jpa.rsql;
import static org.eclipse.hawkbit.repository.rsql.RsqlConfigHolder.RsqlToSpecBuilder.LEGACY_G2;
import static org.eclipse.hawkbit.repository.rsql.RsqlConfigHolder.RsqlToSpecBuilder.G3;
import static org.eclipse.hawkbit.repository.rsql.RsqlConfigHolder.RsqlToSpecBuilder.LEGACY_G1;
import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.NONE;
import jakarta.persistence.EntityManager;
@@ -42,6 +45,11 @@ class RSQLToSQLTest {
private static final boolean FULL = Boolean.getBoolean("full");
private RSQLToSQL rsqlToSQL;
@Test
void p() {
print(JpaTarget.class, TargetFields.class, TargetFields.TAG.name() + "==''");
print(JpaTarget.class, TargetFields.class, TargetFields.TAG.name() + "!=''");
}
@Test
void print() {
print(JpaTarget.class, TargetFields.class, "tag==tag1 and tag==tag2");
@@ -105,18 +113,28 @@ class RSQLToSQLTest {
private <T, A extends Enum<A> & RsqlQueryField> void print(final Class<T> domainClass, final Class<A> fieldsClass, final String rsql) {
System.out.println(rsql);
System.out.println("\tlegacy:\n" +
"\t\t" + useStar(rsqlToSQL.toSQL(domainClass, fieldsClass, rsql, true)));
System.out.println("\tG2:\n" +
"\t\t" + useStar(rsqlToSQL.toSQL(domainClass, fieldsClass, rsql, false)));
final String legacy = rsqlToSQL.toSQL(domainClass, fieldsClass, rsql, LEGACY_G1);
final String g2 = rsqlToSQL.toSQL(domainClass, fieldsClass, rsql, LEGACY_G2);
final String g3 = rsqlToSQL.toSQL(domainClass, fieldsClass, rsql, G3);
System.out.println("\tlegacy:\n\t\t" + useStar(legacy));
System.out.println("\tG2:\n\t\t" + useStar(g2));
System.out.println("\tG3:\n\t\t" + useStar(g3));
if (!g2.equals(g3)) {
System.out.println("\tG2 != G3 for rsql: " + rsql);
}
}
private <T, A extends Enum<A> & RsqlQueryField> void printFrom(final Class<T> domainClass, final Class<A> fieldsClass, final String rsql) {
System.out.println(rsql);
System.out.println("\tlegacy:\n" +
"\t\t" + from(rsqlToSQL.toSQL(domainClass, fieldsClass, rsql, true)));
System.out.println("\tG2:\n" +
"\t\t" + from(rsqlToSQL.toSQL(domainClass, fieldsClass, rsql, false)));
final String legacy = rsqlToSQL.toSQL(domainClass, fieldsClass, rsql, LEGACY_G1);
final String g2 = rsqlToSQL.toSQL(domainClass, fieldsClass, rsql, LEGACY_G2);
final String g3 = rsqlToSQL.toSQL(domainClass, fieldsClass, rsql, G3);
System.out.println("\tlegacy:\n\t\t" + from(legacy));
System.out.println("\tG2:\n\t\t" + from(g2));
System.out.println("\tG2:\n\t\t" + from(g3));
if (!g2.equals(g3)) {
System.out.println("\tG2 != G3 for rsql: " + rsql);
}
}
private static String useStar(final String sql) {

View File

@@ -55,6 +55,7 @@ import org.eclipse.hawkbit.repository.rsql.VirtualPropertyResolver;
import org.eclipse.hawkbit.security.SystemSecurityContext;
import org.eclipse.hawkbit.tenancy.configuration.TenantConfigurationProperties.TenantConfigurationKey;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
@@ -70,6 +71,7 @@ import org.springframework.test.context.junit.jupiter.SpringExtension;
@ExtendWith(SpringExtension.class)
@Feature("Component Tests - Repository")
@Story("RSQL search utility")
@Disabled
// TODO: fully document tests -> @Description for long text and reasonable
// method name as short text
class RSQLUtilityTest {

View File

@@ -0,0 +1,95 @@
/**
* Copyright (c) 2025 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.rsql;
import static org.assertj.core.api.Assertions.assertThat;
import static org.eclipse.hawkbit.repository.jpa.rsql.RsqlParser.parse;
import org.eclipse.hawkbit.repository.TargetFields;
import org.junit.jupiter.api.Test;
class RsqlParserTest {
@Test
void testSimpleComparisonNode() {
assertThat(parse("a == 6")).hasToString("a == \"6\"");
assertThat(parse("a != 6")).hasToString("a != \"6\"");
assertThat(parse("a > 6")).hasToString("a > \"6\"");
assertThat(parse("a >= 6")).hasToString("a >= \"6\"");
assertThat(parse("a < 6")).hasToString("a < \"6\"");
assertThat(parse("a <= 6")).hasToString("a <= \"6\"");
assertThat(parse("a =in= 6")).hasToString("a in (\"6\")");
assertThat(parse("a =in= (6)")).hasToString("a in (\"6\")");
assertThat(parse("a =in= (6, 7)")).hasToString("a in (\"6\", \"7\")");
assertThat(parse("a =out= 6")).hasToString("a not in (\"6\")");
assertThat(parse("a =out= (6)")).hasToString("a not in (\"6\")");
assertThat(parse("a =out= (6, 7)")).hasToString("a not in (\"6\", \"7\")");
}
@Test
void testNullComparison() {
assertThat(parse("a == null")).hasToString("a == \"null\"");
assertThat(parse("a =is= 6")).hasToString("a == \"6\"");
assertThat(parse("a =is= null")).hasToString("a == null");
assertThat(parse("a != null")).hasToString("a != \"null\"");
assertThat(parse("a =not= 6")).hasToString("a != \"6\"");
assertThat(parse("a =not= null")).hasToString("a != null");
}
@Test
void testLikeComparison() {
assertThat(parse("a == test*")).hasToString("a like \"test*\"");
assertThat(parse("a == test\\*")).hasToString("a == \"test\\*\"");
assertThat(parse("a != test*")).hasToString("a not like \"test*\"");
assertThat(parse("a != test\\*")).hasToString("a != \"test\\*\"");
}
@Test
void testOrLogical() {
assertThat(parse("a == 6 or a == 7")).hasToString("a == \"6\" || a == \"7\"");
assertThat(parse("a == 6 or a == 7 or a == '8'")).hasToString("a == \"6\" || a == \"7\" || a == \"8\"");
assertThat(parse("(a == 6 or a == 7) or a == '8'")).hasToString("a == \"6\" || a == \"7\" || a == \"8\"");
assertThat(parse("a == 6 or (a == 7 or a == '8')")).hasToString("a == \"6\" || a == \"7\" || a == \"8\"");
assertThat(parse("a == 6 or a == 7 or (a == '8')")).hasToString("a == \"6\" || a == \"7\" || a == \"8\"");
}
@Test
void testAndLogical() {
assertThat(parse("a == 6 and a == 7")).hasToString("a == \"6\" && a == \"7\"");
assertThat(parse("a == 6 and a == 7 and a == '8'")).hasToString("a == \"6\" && a == \"7\" && a == \"8\"");
assertThat(parse("(a == 6 and a == 7) and a == '8'")).hasToString("a == \"6\" && a == \"7\" && a == \"8\"");
assertThat(parse("a == 6 and (a == 7 and a == '8')")).hasToString("a == \"6\" && a == \"7\" && a == \"8\"");
assertThat(parse("a == 6 and a == 7 and (a == '8')")).hasToString("a == \"6\" && a == \"7\" && a == \"8\"");
}
@Test
void testComplexLogical() {
assertThat(parse("a == 6 and a == 7 or a == '8'")).hasToString("a == \"6\" && a == \"7\" || a == \"8\"");
assertThat(parse("(a == 6 or a == 7) and a == '8'")).hasToString("(a == \"6\" || a == \"7\") && a == \"8\"");
assertThat(parse("a == 6 or (a == 7 and a == '8')")).hasToString("a == \"6\" || a == \"7\" && a == \"8\"");
assertThat(parse("a == 6 and (a == 7 or a == '8')")).hasToString("a == \"6\" && (a == \"7\" || a == \"8\")");
}
@Test
void testQueryFieldsComparisonNode() {
// simple attribute
assertThat(parse("controllerid == 6", TargetFields.class)).hasToString("controllerId == \"6\"");
// reference attribute
assertThat(parse("assignedds.name == 6", TargetFields.class)).hasToString("assignedDistributionSet.name == \"6\"");
// reference attribute with default sub-attribute
assertThat(parse("tag == 6", TargetFields.class)).hasToString("tags.name == \"6\"");
assertThat(parse("tag.name == 6", TargetFields.class)).hasToString("tags.name == \"6\"");
// map attribute
assertThat(parse("metadata.x == 6", TargetFields.class)).hasToString("metadata.x == \"6\"");
assertThat(parse("metadata.x.y.z == 6", TargetFields.class)).hasToString("metadata.x.y.z == \"6\""); // with dots
}
}