Rsql G3 (#2423)
* 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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user