Extract QL support in a top level module (#2808)

Signed-off-by: Avgustin Marinov <Avgustin.Marinov@bosch.com>
This commit is contained in:
Avgustin Marinov
2025-11-14 14:19:36 +02:00
committed by GitHub
parent df4464406e
commit c5ea265e0f
71 changed files with 454 additions and 485 deletions

View File

@@ -0,0 +1,313 @@
/**
* 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.ql;
import static org.eclipse.hawkbit.ql.Node.Comparison.Operator.EQ;
import static org.eclipse.hawkbit.ql.Node.Comparison.Operator.GT;
import static org.eclipse.hawkbit.ql.Node.Comparison.Operator.GTE;
import static org.eclipse.hawkbit.ql.Node.Comparison.Operator.IN;
import static org.eclipse.hawkbit.ql.Node.Comparison.Operator.LIKE;
import static org.eclipse.hawkbit.ql.Node.Comparison.Operator.LT;
import static org.eclipse.hawkbit.ql.Node.Comparison.Operator.LTE;
import static org.eclipse.hawkbit.ql.Node.Comparison.Operator.NE;
import static org.eclipse.hawkbit.ql.Node.Comparison.Operator.NOT_IN;
import static org.eclipse.hawkbit.ql.Node.Comparison.Operator.NOT_LIKE;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.util.Arrays;
import java.util.Collection;
import java.util.Map;
import java.util.Objects;
import java.util.function.BiPredicate;
import org.eclipse.hawkbit.ql.Node.Comparison.Operator;
import org.springframework.core.ResolvableType;
/**
* Provides entity matcher that matches an entity object against a filter (a {@link Node} or an RSQL string).
*/
public class EntityMatcher {
private final Node root;
private final boolean ignoreCase;
private EntityMatcher(final Node root, final boolean ignoreCase) {
this.root = root;
this.ignoreCase = ignoreCase;
}
public static EntityMatcher of(final Node root) {
return of(root, false);
}
public static EntityMatcher of(final Node root, final boolean ignoreCase) {
return new EntityMatcher(root, ignoreCase);
}
public <T> boolean match(final T t) {
return match(t, root);
}
@SuppressWarnings({ "java:S3776", "java:S3358", "java:S1125", "java:S6541" }) // better readable this way
private <T> boolean match(final T t, final Node node) {
if (node instanceof Node.Comparison comparison) {
final String[] split = comparison.getKey().split("\\.", 2);
try {
final Getter fieldGetter = getGetter(t.getClass(), split[0]);
final Object fieldValue = fieldGetter.get(t);
final Operator op = comparison.getOp();
if (Map.class.isAssignableFrom(getReturnType(fieldGetter))) {
if ((op == NE || op == NOT_IN || op == NOT_LIKE)
&& (fieldValue == null || !((Map<?, ?>) fieldValue).containsKey(split[1]))) {
// TODO / recheck - when missing entity shall it be included or not in != or =out=? - now it's not
return false;
}
return compareIgnoreCaseAware(
fieldValue == null ? null : ((Map<?, ?>) fieldValue).get(split[1]),
op,
map(
comparison.getValue(),
(Class<?>) ((ParameterizedType) fieldGetter.type()).getActualTypeArguments()[1]));
} else if (Collection.class.isAssignableFrom(getReturnType(fieldGetter))) { // Set / List
final Object value;
final BiPredicate<Object, Operator> compare;
if (split.length == 1) {
value = map(comparison.getValue(), getReturnType(fieldGetter));
compare = (e, operator) -> compareIgnoreCaseAware(e, operator, value);
} else {
final Getter valueGetter = getGetter(
(Class<?>) ((ParameterizedType) fieldGetter.type()).getActualTypeArguments()[0], split[1]);
value = map(comparison.getValue(), getReturnType(valueGetter));
compare = (e, operator) -> {
try {
return compareIgnoreCaseAware(
map(e == null ? null : valueGetter.get(e), getReturnType(valueGetter)), operator, value);
} catch (final IllegalAccessException | InvocationTargetException ex) {
throw new IllegalArgumentException(ex);
}
};
}
final Collection<?> set = (Collection<?>) fieldValue;
return switch (op) {
case EQ, GT, GTE, LT, LTE, IN, LIKE -> set == null
? false
: set.stream().anyMatch(e -> compare.test(e, op));
case NE, NOT_IN, NOT_LIKE -> set == null
? true
: set.stream().noneMatch(e -> compare.test(e, op == NE ? EQ : op == NOT_IN ? IN : LIKE));
};
} else {
if (split.length == 1) {
return compareIgnoreCaseAware(fieldValue, op, map(comparison.getValue(), getReturnType(fieldGetter)));
} else {
if (split[1].contains(".")) {
// nested field access
final String[] nestedSplit = split[1].split("\\.", 2);
final Getter nestedFieldGetter = getGetter(getReturnType(fieldGetter), nestedSplit[0]);
final Getter valueGetter = getGetter(getReturnType(nestedFieldGetter), nestedSplit[1]);
final Object nestedFieldValue = fieldValue == null ? null : nestedFieldGetter.get(fieldValue);
return compareIgnoreCaseAware(
nestedFieldValue == null ? null : valueGetter.get(nestedFieldValue),
op,
map(comparison.getValue(), getReturnType(valueGetter)));
} else {
final Getter valueGetter = getGetter(getReturnType(fieldGetter), split[1]);
return compareIgnoreCaseAware(
fieldValue == null ? null : valueGetter.get(fieldValue),
op,
map(comparison.getValue(), getReturnType(valueGetter)));
}
}
}
} catch (final NoSuchMethodException | IllegalAccessException | InvocationTargetException e) {
throw new IllegalArgumentException(e);
}
} else if (node instanceof Node.Logical logical) {
return switch (logical.getOp()) {
case AND -> logical.getChildren().stream().allMatch(child -> match(t, child));
case OR -> logical.getChildren().stream().anyMatch(child -> match(t, child));
};
} else {
throw new IllegalArgumentException("Unsupported node type: " + node.getClass());
}
}
private boolean compareIgnoreCaseAware(final Object entityValue, final Operator op, final Object comparisonValue) {
return compare(ignoreCase(entityValue), op, ignoreCase(comparisonValue));
}
private Object ignoreCase(final Object o) {
if (!ignoreCase || o == null) {
return o;
}
// if here - ignoreCase in true and we have non-null value
if (o instanceof String str) {
return str.toLowerCase();
} else if (o instanceof Collection<?> collection) {
return collection.stream().map(this::ignoreCase).toList();
} else {
return o;
}
}
// java:S3011 uses reflection to private members anyway
// java:S3358 - better readable this way
@SuppressWarnings({ "java:S3011", "java:S3358" })
private static <T> Getter getGetter(final Class<T> t, final String fieldName) throws NoSuchMethodException {
final String[] parts = fieldName.split("\\.");
if (parts.length > 1) {
final Getter firstGetter = getGetter(t, parts[0]);
final Getter nextGetter = getGetter(t, fieldName.substring(parts[0].length() + 1));
return new Getter() {
@Override
public Object get(final Object obj) throws IllegalAccessException, InvocationTargetException {
return nextGetter.get(firstGetter.get(obj));
}
@Override
public Type type() {
return nextGetter.type();
}
};
}
final String getterLowercase = "get" + fieldName.toLowerCase();
return Arrays.stream(t.getMethods())
.filter(method -> getterLowercase.equals(method.getName().toLowerCase()))
.findFirst()
.map(Method::getName)
.map(getterName -> {
try {
// gets method via Class.getMethod(String, Class<?>...) because in listing it might have not
// the correct return type, but the type got from a declaring generic type
final Method getter = t.getMethod(getterName);
getter.setAccessible(true);
return new Getter() {
@Override
public Object get(final Object obj) throws IllegalAccessException, InvocationTargetException {
return getter.invoke(obj);
}
@Override
public Type type() {
final Type type = getter.getGenericReturnType();
return type instanceof Class<?>
? type
: type instanceof ParameterizedType
? type // Map or Collection generic type
: ResolvableType.forMethodReturnType(getter, t).resolve();
}
};
} catch (final NoSuchMethodException e) {
throw new IllegalStateException("Unexpected: No getter found for field: " + fieldName + " in class: " + t.getName(), e);
}
}).orElseThrow(() -> new NoSuchMethodException("No getter found for field: " + fieldName + " in class: " + t.getName()));
}
private static Class<?> getReturnType(final Getter getter) {
return getter.type() instanceof Class<?> clazz ? clazz : (Class<?>) ((ParameterizedType) getter.type()).getRawType();
}
@SuppressWarnings({ "unchecked", "rawtypes", "java:S3776" }) // java:S3776 - better readable this way
private static Object map(final Object value, final Class<?> type) {
if (value instanceof Collection<?> collection) { // in / out
return collection.stream().map(e -> map(e, type)).toList();
}
if (value == null) {
return null;
} else if (type.isInstance(value)) {
return value;
} else if (type.isEnum()) {
return Enum.valueOf((Class<Enum>) type, value.toString());
} else if (type == Boolean.class || type == boolean.class) {
return Boolean.parseBoolean(value.toString());
} else if (type == Integer.class || type == int.class) {
return Integer.parseInt(value.toString());
} else if (type == Long.class || type == long.class) {
return Long.parseLong(value.toString());
} else if (type == Float.class || type == float.class) {
return Float.parseFloat(value.toString());
} else if (type == Double.class || type == double.class) {
return Double.parseDouble(value.toString());
} else if (type == String.class) {
return String.valueOf(value);
} else {
throw new IllegalArgumentException("Unsupported type: " + type);
}
}
private static boolean compare(final Object entityValue, final Operator op, final Object comparisonValue) {
if ((entityValue == null || comparisonValue == null) && // null is not comparable!
(op == GT || op == GTE || op == LT || op == LTE)) {
return false;
}
return switch (op) {
case EQ -> Objects.equals(entityValue, comparisonValue);
case NE -> !Objects.equals(entityValue, comparisonValue);
case GT -> compare(entityValue, comparisonValue) > 0;
case GTE -> compare(entityValue, comparisonValue) >= 0;
case LT -> compare(entityValue, comparisonValue) < 0;
case LTE -> compare(entityValue, comparisonValue) <= 0;
case IN -> in(entityValue, comparisonValue);
case NOT_IN -> !in(entityValue, comparisonValue);
case LIKE -> like(comparisonValue, entityValue);
case NOT_LIKE -> !like(comparisonValue, entityValue);
};
}
@SuppressWarnings("unchecked")
private static int compare(final Object o1, final Object o2) {
return toComparable(o1).compareTo(toComparable(o2));
}
@SuppressWarnings("rawtypes")
private static Comparable toComparable(final Object o) {
if (o instanceof Comparable<?> comparable) {
return comparable;
} else {
throw new IllegalArgumentException("Can't cast " + o.getClass() + " to Comparable");
}
}
private static boolean in(final Object o, final Object elementOrCollection) {
if (elementOrCollection instanceof Collection<?> collection) {
return collection.contains(o);
} else {
return Objects.equals(o, elementOrCollection);
}
}
private static boolean like(final Object pattern, final Object value) {
if (pattern instanceof String patternStr) {
if (value instanceof String valueStr) {
return valueStr.matches(patternStr.replace("\\*", "$").replace("*", ".*").replace("$", "\\*"));
} else if (value == null) {
return false; // null value cannot match any pattern
} else {
throw new IllegalArgumentException("LIKE value must be String. Found: " + value.getClass());
}
} else {
throw new IllegalArgumentException("LIKE pattern must be String. Found: " + (pattern == null ? null : pattern.getClass()));
}
}
private interface Getter {
Object get(Object obj) throws IllegalAccessException, InvocationTargetException;
Type type();
}
}

View File

@@ -0,0 +1,231 @@
/**
* 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.ql;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.function.UnaryOperator;
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);
}
// utility method that maps this node with a transformer that could modify comparisons - e.g. change keys, values, operators, or whatever
// if there are no changes the same instance is returned
default Node transform(final UnaryOperator<Comparison> transformer) {
if (this instanceof Comparison comparison) {
return transformer.apply(comparison);
} else {
final List<Node> mappedChildren = new ArrayList<>();
boolean modified = false;
for (final Node child : ((Logical) this).getChildren()) {
final Node mapped = child.transform(transformer);
mappedChildren.add(mapped);
if (!mapped.equals(child)) {
modified = true;
}
}
return modified ? new Logical(((Logical) this).getOp(), mappedChildren) : this;
}
}
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(final 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

@@ -0,0 +1,44 @@
/**
* 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.ql;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.ToString;
/**
* Exception used by the REST API in case of RSQL search filter query.
*/
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
public class QueryException extends RuntimeException {
public enum ErrorCode {
INVALID_SYNTAX,
UNSUPPORTED_FIELD,
GENERIC // an other
}
@Getter
private final ErrorCode errorCode;
public QueryException(final ErrorCode errorCode, final String message) {
this(errorCode, message, null);
}
public QueryException(final ErrorCode errorCode, final Throwable cause) {
this(errorCode, null, cause);
}
public QueryException(final ErrorCode errorCode, final String message, final Throwable cause) {
super(message, cause);
this.errorCode = errorCode;
}
}

View File

@@ -0,0 +1,71 @@
/**
* 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.ql;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import lombok.NonNull;
/**
* A query field interface extended by all the fields that could be used in queries.
*/
public interface QueryField {
/**
* Separator for the sub attributes
*/
String SUB_ATTRIBUTE_SEPARATOR = ".";
String SUB_ATTRIBUTE_SPLIT_REGEX = "\\" + SUB_ATTRIBUTE_SEPARATOR;
/**
* Returns the entity field name, e.g. the JPA entity field name.
*
* @return the string representation of the underlying persistence field name.
*/
@NonNull
String getName();
/**
* @return all sub entities attributes.
*/
default List<String> getSubEntityAttributes() {
return Collections.emptyList();
}
/**
* Return the default sub entity attribute if available. This allows to skip that sub-attribute and call with "shortcut" - the enum name
*
* @return the default sub-attribute or <code>null</code>> if no default is available
*/
default String getDefaultSubEntityAttribute() {
final List<String> subAttributes = getSubEntityAttributes();
return subAttributes.size() == 1 ? subAttributes.get(0) : null;
}
/**
* Returns the name of the field, that identifies the entity.
*
* @return the name of the identifier, by default 'id'
*/
default String getIdentifierFieldName() {
return "id";
}
/**
* Is the entity field a {@link Map} consisting of key-value pairs.
*
* @return <code>true</code> is a map <code>false</code> is not a map
*/
default boolean isMap() {
return false;
}
}

View File

@@ -0,0 +1,244 @@
/**
* 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.ql.jpa;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import jakarta.persistence.EntityManager;
import jakarta.persistence.criteria.CriteriaBuilder;
import jakarta.persistence.criteria.CriteriaQuery;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.NonNull;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.hawkbit.ql.EntityMatcher;
import org.eclipse.hawkbit.ql.Node;
import org.eclipse.hawkbit.ql.Node.Comparison;
import org.eclipse.hawkbit.ql.QueryException;
import org.eclipse.hawkbit.ql.QueryField;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.ApplicationListener;
import org.springframework.context.event.ContextRefreshedEvent;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.data.jpa.domain.Specification;
/**
* A utility class which is able to parse RSQL strings into an spring data {@link Specification} which then can be enhanced sql queries to
* filter entities. It has out of the box support for the RSQL parser library: https://github.com/jirutka/rsql-parser
*
* <ul>
* <li>{@code Equal to: ==}</li>
* <li>{@code Not equal to: !=}</li>
* <li>{@code Less than: =lt= or <}</li>
* <li>{@code Less than or equal to: =le= or <=}</li>
* <li>{@code Greater than operator: =gt= or >}</li>
* <li>{@code Greater than or equal to: =ge= or >=}</li>
* </ul>
* <p>
* Examples of RSQL expressions in both FIQL-like and alternative notation:
* <ul>
* <li>{@code version==2.0.0}</li>
* <li>{@code name==targetId1;description==plugAndPlay}</li>
* <li>{@code name==targetId1 and description==plugAndPlay}</li>
* <li>{@code name==targetId1,description==plugAndPlay,updateStatus==UNKNOWN}</li>
* <li>{@code name==targetId1 or description==plugAndPlay or updateStatus==UNKNOWN}</li>
* </ul>
* <p>
* There is also a mechanism that allows to refer to known macros that can resolved by an optional {@link StrLookup} (cp.
* {@link VirtualPropertyResolver}).<br>
* An example that queries for all overdue targets using the ${OVERDUE_TS} placeholder introduced by {@link VirtualPropertyResolver} looks
* like this:<br>
* <em>lastControllerRequestAt=le=${OVERDUE_TS}</em><br>
* It is possible to escape a macro expression by using a second '$': $${OVERDUE_TS} would prevent the ${OVERDUE_TS} token from being expanded.
*/
@Slf4j
@Getter
@NoArgsConstructor(access = AccessLevel.PRIVATE)
@SuppressWarnings("java:S6548") // singleton holder ensures static access to spring resources in some places
public class QLSupport implements ApplicationListener<ContextRefreshedEvent> {
private static final QLSupport SINGLETON = new QLSupport();
/**
* If QL comparison operators shall ignore the case. If ignore case is <code>true</code> "x == ax" will match "x == aX"
*/
@Value("${hawkbit.ql.ignore-case:true}")
private boolean ignoreCase;
/**
* Declares if the database is case-insensitive, by default assumes <code>false</code>. In case it is case-sensitive and,
* {@link #ignoreCase} is set to <code>true</code> the SQL queries use upper case comparisons to ignore case.
* <p/>
* If the database is declared as case-sensitive and ignoreCase is set to <code>false</code> the RSQL queries shall use strict
* syntax - i.e. 'and' instead of 'AND' / 'aND'. Otherwise, the queries would be case-insensitive regarding operators.
*/
@Value("${hawkbit.ql.case-insensitive-db:false}")
private boolean caseInsensitiveDB;
private QueryParser parser;
private List<NodeTransformer> nodeTransformers = List.of();
private EntityManager entityManager;
/**
* @return The holder singleton instance.
*/
public static QLSupport getInstance() {
return SINGLETON;
}
@Autowired
public void setQueryParser(final QueryParser parser) {
this.parser = parser;
}
@Override
public void onApplicationEvent(@NonNull final ContextRefreshedEvent event) {
nodeTransformers = event.getApplicationContext().getBeansOfType(NodeTransformer.class).values().stream().sorted((b1, b2) -> {
final Order o1 = b1.getClass().getAnnotation(Order.class);
final Order o2 = b2.getClass().getAnnotation(Order.class);
return Integer.compare(o1 != null ? o1.value() : Ordered.LOWEST_PRECEDENCE, o2 != null ? o2.value() : Ordered.LOWEST_PRECEDENCE);
}).toList();
}
@Autowired
void setEntityManager(final EntityManager entityManager) {
this.entityManager = entityManager;
}
public Node parse(final String query) {
return parse(query, null);
}
public <A extends Enum<A> & QueryField> Node parse(final String query, final Class<A> queryFieldType) {
return parser.parse(ignoreCase || caseInsensitiveDB ? query.toLowerCase() : query, queryFieldType);
}
/**
* 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 query the rsql query to be parsed
* @param queryFieldType the enum class type which implements the {@link QueryField}
* @return a specification which can be used with JPA
* @throws QueryException if the RSQL syntax is wrong or if a field in the RSQL string is used but not provided by the given {@code queryFieldType}
*/
public <A extends Enum<A> & QueryField, T> Specification<T> buildSpec(final String query, final Class<A> queryFieldType) {
return new SpecificationBuilder<T>(ignoreCase && !caseInsensitiveDB)
.specification(transform(parse(query, queryFieldType), queryFieldType));
}
public <A extends Enum<A> & QueryField, T> Specification<T> buildSpec(final Node query, final Class<A> queryFieldType) {
return new SpecificationBuilder<T>(ignoreCase && !caseInsensitiveDB).specification(transform(query, queryFieldType));
}
@SuppressWarnings("java:S1117") // it is again ignoreCase
public <A extends Enum<A> & QueryField> EntityMatcher entityMatcher(final String query, final Class<A> queryFieldType) {
return EntityMatcher.of(transform(parse(query, queryFieldType), queryFieldType), ignoreCase || caseInsensitiveDB);
}
/**
* Validates the query string
*
* @param query query string to validate
* @param queryFieldType the enum class type which implements the {@link QueryField}
* @param jpaType the JPA entity type to validate against
* @throws QueryException if query is invalid
*/
@SuppressWarnings({ "unchecked", "rawtypes" })
public <A extends Enum<A> & QueryField> void validate(final String query, final Class<A> queryFieldType, final Class<?> jpaType) {
final CriteriaBuilder criteriaBuilder = entityManager.getCriteriaBuilder();
final CriteriaQuery<?> criteriaQuery = criteriaBuilder.createQuery(jpaType);
buildSpec(query, queryFieldType).toPredicate(criteriaQuery.from((Class) jpaType), criteriaQuery, criteriaBuilder);
}
private <A extends Enum<A> & QueryField> Node transform(Node node, final Class<A> queryFieldType) {
for (final NodeTransformer transformer : nodeTransformers) {
node = transformer.transform(node, queryFieldType);
}
return node;
}
/**
* By registering a custom {@link QueryParser} (as a {@link org.springframework.context.annotation.Bean}) the entire parsing of the queries
* could be replaced / customized, e.g. the default query language (RSQL) could be replaced with a custom.
*/
public interface QueryParser {
<T extends Enum<T> & QueryField> Node parse(final String query, final Class<T> queryFieldType) throws QueryException;
}
/**
* By registering a custom {@link NodeTransformer} (as a {@link org.springframework.context.annotation.Bean}) the nodes could be
* modified after parsing, e.g. to add implicit nodes or to modify values.
* <p/>
* By default, all transformers are with {@link Ordered#LOWEST_PRECEDENCE} order. So, if you need a specific order use the {@link Order}
* annotation of their class (not on the bean registering methods).
*/
public interface NodeTransformer {
<T extends Enum<T> & QueryField> Node transform(Node node, final Class<T> queryFieldType);
/**
* Base implementation that does no real transformation but allows extenders to easily modify keys and / or values by simply extending
* the extension points.
*/
abstract class Abstract implements NodeTransformer {
public <T extends Enum<T> & QueryField> Node transform(final Node node, final Class<T> queryFieldType) {
return node.transform(comparison -> transform(comparison, queryFieldType));
}
protected <T extends Enum<T> & QueryField> Comparison transform(final Comparison comparison, final Class<T> queryFieldType) {
final String key = transformKey(comparison.getKey(), comparison, queryFieldType).toString();
final Object value = transformValue(comparison.getValue(), comparison, queryFieldType);
return key.equals(comparison.getKey()) && Objects.equals(value, comparison.getValue())
? comparison : Comparison.builder().key(key).op(comparison.getOp()).value(value).build();
}
// just extension points for subclasses
@SuppressWarnings("java:S1172") // comparison and queryFieldType might be useful for subclasses
protected <T extends Enum<T> & QueryField> Object transformKey(
final String key, final Comparison comparison, final Class<T> queryFieldType) {
return key;
}
// internal, override only if you really want to replace whole lists
protected <T extends Enum<T> & QueryField> Object transformValue(
final Object value, final Comparison comparison, final Class<T> queryFieldType) {
if (value instanceof List<?> list) {
final List<Object> mappedList = new ArrayList<>();
boolean modified = false;
for (final Object e : list) {
final Object mapped = transformValueElement(e, comparison, queryFieldType);
if (!Objects.equals(mapped, value)) {
modified = true;
}
mappedList.add(mapped);
}
return modified ? mappedList : list;
} else {
return transformValueElement(value, comparison, queryFieldType);
}
}
// just extension points for subclasses
@SuppressWarnings("java:S1172") // comparison and queryFieldType might be useful for subclasses
protected <T extends Enum<T> & QueryField> Object transformValueElement(
final Object value, final Comparison comparison, final Class<T> queryFieldType) {
return value;
}
}
}
}

View File

@@ -0,0 +1,510 @@
/**
* 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.ql.jpa;
import static org.eclipse.hawkbit.ql.Node.Comparison.Operator.GT;
import static org.eclipse.hawkbit.ql.Node.Comparison.Operator.GTE;
import static org.eclipse.hawkbit.ql.Node.Comparison.Operator.IN;
import static org.eclipse.hawkbit.ql.Node.Comparison.Operator.LIKE;
import static org.eclipse.hawkbit.ql.Node.Comparison.Operator.LT;
import static org.eclipse.hawkbit.ql.Node.Comparison.Operator.LTE;
import static org.eclipse.hawkbit.ql.Node.Comparison.Operator.NE;
import static org.eclipse.hawkbit.ql.Node.Comparison.Operator.NOT_IN;
import static org.eclipse.hawkbit.ql.Node.Comparison.Operator.NOT_LIKE;
import static org.eclipse.hawkbit.ql.QueryException.ErrorCode.INVALID_SYNTAX;
import static org.eclipse.hawkbit.ql.QueryException.ErrorCode.UNSUPPORTED_FIELD;
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 java.util.stream.Collectors;
import jakarta.annotation.Nonnull;
import jakarta.persistence.PersistenceException;
import jakarta.persistence.criteria.CriteriaBuilder;
import jakarta.persistence.criteria.CriteriaQuery;
import jakarta.persistence.criteria.Expression;
import jakarta.persistence.criteria.Join;
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.Getter;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.hawkbit.ql.Node;
import org.eclipse.hawkbit.ql.Node.Comparison;
import org.eclipse.hawkbit.ql.Node.Comparison.Operator;
import org.eclipse.hawkbit.ql.Node.Logical;
import org.eclipse.hawkbit.ql.QueryException;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.util.ObjectUtils;
@Slf4j
public class SpecificationBuilder<T> {
private static final char ESCAPE_CHAR = '\\';
private final boolean ensureIgnoreCase;
public SpecificationBuilder(final boolean ensureIgnoreCase) {
this.ensureIgnoreCase = ensureIgnoreCase;
}
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) {
final Map<String, Integer> state = pathResolver.getState();
return cb.or(logical.getChildren().stream()
.map(child -> {
pathResolver.reset(state);
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("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 QueryException(
UNSUPPORTED_FIELD,
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(toMapValuePath(pathResolver.getJoinOn(attribute, split[1])));
case NE -> cb.isNotNull(toMapValuePath(pathResolver.getJoinOn(attribute, split[1])));
default -> throw new QueryException(
INVALID_SYNTAX,
String.format("Operator %s is not supported for map fields with value null", op));
};
} else {
final MapJoin<?, ?, ?> mapPath = (MapJoin<?, ?, ?>) pathResolver.getPath(attribute);
return isNot(op)
? compare(comparison, toMapValuePath(pathResolver.getJoinOnInner(attribute, split[1])))
: cb.and(equal(mapPath.key(), split[1]), compare(comparison, toMapValuePath(mapPath)));
}
} else if (attribute instanceof SetAttribute<?, ?> setAttribute) {
if (split.length < 2 || ObjectUtils.isEmpty(split[1])) {
throw new QueryException(
UNSUPPORTED_FIELD,
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(deepGetPath(pathResolver.getPath(attribute), split[1])));
}
return compare(comparison, deepGetPath(pathResolver.getPath(attribute), 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 ? deepGetPath(attributePath, split[1]) : attributePath);
}
}
@SuppressWarnings("unchecked")
private static Path<String> toMapValuePath(final Path<?> mapJoin) {
final Path<?> valuePath = ((MapJoin<?, ?, ?>) mapJoin).value();
return valuePath.getJavaType() == String.class ? (Path<String>) valuePath : valuePath.get("value");
}
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()
: deepGetPath(subqueryRoot.join(pluralAttribute.getName(), JoinType.LEFT), 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()
// converts value to the correct type
.map(element -> element instanceof String strElement
? convertValueIfNecessary(strElement, javaType, comparison)
: element)
.toList();
if (values.isEmpty()) {
throw new QueryException(INVALID_SYNTAX, "RSQL values must not be empty");
} 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 QueryException(INVALID_SYNTAX, op + " operator could not be applied number");
}
} 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 QueryException(INVALID_SYNTAX, op + " operator could not be applied to " + errorMsg);
}
}
} else {
final Operator op = comparison.getOp();
if (op != IN && op != NOT_IN) {
throw new QueryException(INVALID_SYNTAX, op + " operator shall have exactly one value");
}
}
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 QueryException(
INVALID_SYNTAX,
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 QueryException(
UNSUPPORTED_FIELD,
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);
}
}
private static Path<?> deepGetPath(final Path<?> path, final String subAttributeName) {
return deepGetPath(path, subAttributeName.split("\\."), 0);
}
private static Path<?> deepGetPath(final Path<?> path, final String[] subAttributeNameSplit, int startIndex) {
final String subAttributeName = subAttributeNameSplit[startIndex++];
if (startIndex == subAttributeNameSplit.length) {
return path.get(subAttributeName);
} else { // else its a deeper path so request left join
if (path instanceof Join<?, ?> join) {
return deepGetPath(join.join(subAttributeName, JoinType.LEFT), subAttributeNameSplit, startIndex);
} else {
throw new QueryException(INVALID_SYNTAX, "Unexpected sub attribute " + subAttributeName);
}
}
}
@SuppressWarnings("unchecked")
private static Path<String> stringPath(final Path<?> path) {
return (Path<String>) path;
}
private String toSqlLikeValue(final String value) {
final String 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);
}
}
@SuppressWarnings("java:S1872") // java:S1872 - sometimes class could be unavailable at runtime
private Predicate like(final Path<String> fieldPath, final String sqlValue) {
try {
if (caseWise(fieldPath)) {
return cb.like(cb.upper(fieldPath), sqlValue.toUpperCase(), ESCAPE_CHAR);
} else {
return cb.like(fieldPath, sqlValue, ESCAPE_CHAR);
}
} catch (final PersistenceException e) {
if ("%".equals(sqlValue) && fieldPath.getJavaType() != String.class &&
"org.hibernate.type.descriptor.java.CoercionException".equals(e.getClass().getName())) {
// hibernate throws an exception if we try to do == on non-string field with wildcard only
return fieldPath.isNotNull();
} else {
throw e;
}
}
}
@SuppressWarnings("java:S1872") // java:S1872 - sometimes class could be unavailable at runtime
private Predicate notLike(final Path<String> fieldPath, final String sqlValue) {
try {
if (caseWise(fieldPath)) {
return cb.notLike(cb.upper(fieldPath), sqlValue.toUpperCase(), ESCAPE_CHAR);
} else {
return cb.notLike(fieldPath, sqlValue, ESCAPE_CHAR);
}
} catch (final PersistenceException e) {
if ("%".equals(sqlValue) && fieldPath.getJavaType() != String.class &&
"org.hibernate.type.descriptor.java.CoercionException".equals(e.getClass().getName())) {
// hibernate throws an exception if we try to do == on non-string field with wildcard only
return fieldPath.isNull();
} else {
throw e;
}
}
}
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 MapJoin<?, ?, ?> getJoinOnInner(final Attribute<?, ?> attribute, final Object value) {
return getCollectionPathResolver(attribute.getName()).getJoinOnInner(value);
}
private Map<String, Integer> getState() {
return attributeToPathResolver.entrySet().stream()
.collect(Collectors.toMap(Map.Entry::getKey, resolver -> resolver.getValue().getPos()));
}
private void reset(final Map<String, Integer> toState) {
attributeToPathResolver.forEach((attribute, resolver) -> resolver.setPos(toState.getOrDefault(attribute, 0)));
}
@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<>();
@Getter
@Setter
private int pos;
private final Map<Object, MapJoin<?, ?, ?>> joinOnCache = new HashMap<>();
private final Map<Object, MapJoin<?, ?, ?>> joinOnInnerCache = 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 MapJoin<?, ?, ?> getJoinOnInner(final Object value) {
return joinOnInnerCache.computeIfAbsent(value, k -> {
final MapJoin<?, ?, ?> mapPath = (MapJoin<?, ?, ?>) root.join(attributeName, JoinType.INNER);
mapPath.on(equal(mapPath.key(), k));
return mapPath;
});
}
}
}
}
}

View File

@@ -0,0 +1,106 @@
/**
* 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.ql.jpa.utils;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.List;
import java.util.Map;
import jakarta.persistence.Query;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.hibernate.engine.spi.SessionFactoryImplementor;
import org.hibernate.engine.spi.SharedSessionContractImplementor;
import org.hibernate.metamodel.mapping.MappingModelExpressible;
import org.hibernate.query.spi.QueryEngine;
import org.hibernate.query.spi.QueryParameterImplementor;
import org.hibernate.query.sqm.internal.QuerySqmImpl;
import org.hibernate.query.sqm.internal.SqmUtil;
import org.hibernate.query.sqm.spi.SqmParameterMappingModelResolutionAccess;
import org.hibernate.query.sqm.sql.SqmTranslation;
import org.hibernate.query.sqm.sql.SqmTranslator;
import org.hibernate.query.sqm.sql.SqmTranslatorFactory;
import org.hibernate.query.sqm.tree.SqmDmlStatement;
import org.hibernate.query.sqm.tree.expression.SqmParameter;
import org.hibernate.query.sqm.tree.select.SqmSelectStatement;
import org.hibernate.sql.ast.SqlAstTranslatorFactory;
import org.hibernate.sql.ast.tree.MutationStatement;
import org.hibernate.sql.ast.tree.Statement;
import org.hibernate.sql.ast.tree.select.SelectStatement;
import org.hibernate.sql.exec.spi.JdbcParameterBindings;
import org.hibernate.sql.exec.spi.JdbcParametersList;
@NoArgsConstructor(access = lombok.AccessLevel.PRIVATE)
@Slf4j
public class HibernateUtils {
private static final Method getSqmTranslatorFactory;
static {
Method method = null;
try {
method = QueryEngine.class.getMethod("getSqmTranslatorFactory");
} catch (final NoSuchMethodException e) {
log.warn("Can't resolve getSqmTranslatorFactory method (HibernateUtils.toString won't work)", e);
}
getSqmTranslatorFactory = method;
}
public static String toSql(final Query query) {
if (getSqmTranslatorFactory == null) {
throw new UnsupportedOperationException("SqmTranslatorFactory resolver is not available");
}
final QuerySqmImpl<?> hqlQuery = query.unwrap(QuerySqmImpl.class);
final SessionFactoryImplementor factory = hqlQuery.getSessionFactory();
final SharedSessionContractImplementor session = hqlQuery.getSession();
final SessionFactoryImplementor sessionFactory = session.getFactory();
final SqmTranslatorFactory sqmTranslatorFactory;
try {
sqmTranslatorFactory = (SqmTranslatorFactory) getSqmTranslatorFactory.invoke(factory.getQueryEngine());
} catch (final IllegalAccessException | InvocationTargetException e) {
throw new UnsupportedOperationException("Can't create SqmTranslatorFactory", e);
}
final SqmTranslator<? extends Statement> sqmSelectTranslator =
hqlQuery.getSqmStatement() instanceof SqmSelectStatement<?> selectStatement
? sqmTranslatorFactory.createSelectTranslator(selectStatement,
hqlQuery.getQueryOptions(), hqlQuery.getDomainParameterXref(), hqlQuery.getQueryParameterBindings(),
hqlQuery.getLoadQueryInfluencers(), sessionFactory, false)
: sqmTranslatorFactory.createMutationTranslator((SqmDmlStatement<?>) hqlQuery.getSqmStatement(),
hqlQuery.getQueryOptions(), hqlQuery.getDomainParameterXref(), hqlQuery.getQueryParameterBindings(),
hqlQuery.getLoadQueryInfluencers(), sessionFactory);
final SqmTranslation<? extends Statement> sqmTranslation = sqmSelectTranslator.translate();
final SqlAstTranslatorFactory sqlAstTranslatorFactory = factory.getJdbcServices().getJdbcEnvironment().getSqlAstTranslatorFactory();
final Map<QueryParameterImplementor<?>, Map<SqmParameter<?>, List<JdbcParametersList>>> jdbcParamsXref = SqmUtil.generateJdbcParamsXref(
hqlQuery.getDomainParameterXref(), sqmTranslation::getJdbcParamsBySqmParam);
final JdbcParameterBindings jdbcParameterBindings = SqmUtil.createJdbcParameterBindings(hqlQuery.getQueryParameterBindings(),
hqlQuery.getDomainParameterXref(), jdbcParamsXref, factory.getRuntimeMetamodels().getMappingMetamodel(),
sqmSelectTranslator.getFromClauseAccess()::findTableGroup, new SqmParameterMappingModelResolutionAccess() {
@Override
@SuppressWarnings("unchecked")
public <T> MappingModelExpressible<T> getResolvedMappingModelType(final SqmParameter<T> parameter) {
return (MappingModelExpressible<T>) sqmTranslation.getSqmParameterMappingModelTypeResolutions().get(parameter);
}
}, hqlQuery.getSession());
return (sqmTranslation.getSqlAst() instanceof SelectStatement selectStatement
? sqlAstTranslatorFactory.buildSelectTranslator(factory, selectStatement)
.translate(jdbcParameterBindings, hqlQuery.getQueryOptions())
: sqlAstTranslatorFactory.buildMutationTranslator(factory, (MutationStatement) sqmTranslation.getSqlAst())
.translate(jdbcParameterBindings, hqlQuery.getQueryOptions()))
.getSqlString();
}
}

View File

@@ -0,0 +1,64 @@
/**
* 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.ql.jpa.utils;
import java.lang.reflect.InvocationTargetException;
import jakarta.persistence.EntityManager;
import jakarta.persistence.Query;
import jakarta.persistence.TypedQuery;
import jakarta.persistence.criteria.CriteriaBuilder;
import jakarta.persistence.criteria.CriteriaQuery;
import org.eclipse.hawkbit.ql.QueryField;
import org.eclipse.hawkbit.ql.jpa.QLSupport;
import org.eclipse.hawkbit.ql.rsql.RsqlParser;
public class QlToSql {
private final EntityManager entityManager;
private final boolean isEclipselink;
public QlToSql(final EntityManager entityManager) {
this.entityManager = entityManager;
isEclipselink = entityManager.getProperties().keySet().stream().anyMatch(key -> key.startsWith("eclipselink."));
}
public <T, A extends Enum<A> & QueryField> String toSQL(final Class<T> domainClass, final Class<A> fieldsClass, final String rsql) {
final CriteriaQuery<T> query = createQuery(domainClass, fieldsClass, rsql);
final TypedQuery<?> typedQuery = entityManager.createQuery(query);
if (isEclipselink) {
try {
return (String) Class.forName("org.eclipse.hawkbit.repository.jpa.EclipselinkUtils")
.getMethod("toSql", Query.class)
.invoke(null, typedQuery);
} catch (final IllegalAccessException | NoSuchMethodException | ClassNotFoundException e) {
throw new IllegalStateException(e);
} catch (final InvocationTargetException e) {
if (e.getCause() instanceof RuntimeException re) {
throw re;
} else {
throw new IllegalStateException(e.getCause());
}
}
} else {
return HibernateUtils.toSql(typedQuery);
}
}
private <T, A extends Enum<A> & QueryField> CriteriaQuery<T> createQuery(
final Class<T> domainClass, final Class<A> fieldsClass, final String rsql) {
final CriteriaQuery<T> query = entityManager.getCriteriaBuilder().createQuery(domainClass);
final CriteriaBuilder cb = entityManager.getCriteriaBuilder();
final QLSupport qlSupport = QLSupport.getInstance();
qlSupport.setQueryParser(RsqlParser::parse);
return query.where(qlSupport.<A, T> buildSpec(rsql, fieldsClass).toPredicate(query.from(domainClass), cb.createQuery(domainClass), cb));
}
}

View File

@@ -0,0 +1,248 @@
/**
* 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.ql.rsql;
import static org.eclipse.hawkbit.ql.Node.Comparison.Operator.EQ;
import static org.eclipse.hawkbit.ql.Node.Comparison.Operator.GT;
import static org.eclipse.hawkbit.ql.Node.Comparison.Operator.GTE;
import static org.eclipse.hawkbit.ql.Node.Comparison.Operator.IN;
import static org.eclipse.hawkbit.ql.Node.Comparison.Operator.LIKE;
import static org.eclipse.hawkbit.ql.Node.Comparison.Operator.LT;
import static org.eclipse.hawkbit.ql.Node.Comparison.Operator.LTE;
import static org.eclipse.hawkbit.ql.Node.Comparison.Operator.NE;
import static org.eclipse.hawkbit.ql.Node.Comparison.Operator.NOT_IN;
import static org.eclipse.hawkbit.ql.Node.Comparison.Operator.NOT_LIKE;
import static org.eclipse.hawkbit.ql.QueryException.ErrorCode.INVALID_SYNTAX;
import static org.eclipse.hawkbit.ql.QueryException.ErrorCode.UNSUPPORTED_FIELD;
import static org.eclipse.hawkbit.ql.QueryField.SUB_ATTRIBUTE_SEPARATOR;
import static org.eclipse.hawkbit.ql.QueryField.SUB_ATTRIBUTE_SPLIT_REGEX;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.function.Function;
import java.util.function.UnaryOperator;
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.ql.Node;
import org.eclipse.hawkbit.ql.Node.Comparison;
import org.eclipse.hawkbit.ql.QueryException;
import org.eclipse.hawkbit.ql.QueryField;
/**
* {@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 RSQL_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);
RSQL_PARSER = new RSQLParser(operators);
}
public static Node parse(final String rsql) {
return parse(rsql, (UnaryOperator<String>) null);
}
public static <T extends Enum<T> & QueryField> Node parse(final String rsql, final Class<T> queryFieldType) {
return parse(rsql, queryFieldType == null ? null : key -> resolveKey(key, queryFieldType));
}
private static Node parse(final String rsql, final UnaryOperator<String> keyResolver) {
try {
return RSQL_PARSER.parse(rsql).accept(new RsqlVisitor(keyResolver));
} catch (final RSQLParserException e) {
throw new QueryException(INVALID_SYNTAX, e);
}
}
@SuppressWarnings("java:S3776") // java:S3776 - group in single method for easier read of whole logic
private static <T extends Enum<T> & QueryField> String 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 QueryException(UNSUPPORTED_FIELD, e);
}
final String attribute;
if (firstSeparatorIndex == -1) { // just field name without sub-attribute
if (enumValue.getSubEntityAttributes().isEmpty()) {
// no sub-attributes -> simple field
attribute = enumValue.getName();
} else {
// just enum name for a complex type (with sub-attributes), should have single (default!) sub-attribute
if (enumValue.isMap()) {
throw new QueryException(UNSUPPORTED_FIELD, "No key specified for a map type " + enumValue);
} else {
final String defaultSubEntityAttribute = enumValue.getDefaultSubEntityAttribute();
if (defaultSubEntityAttribute != null) {
// single sub attribute - so, treat it as a default
attribute = enumValue.getName() + SUB_ATTRIBUTE_SEPARATOR + defaultSubEntityAttribute;
} else {
throw new QueryException(
UNSUPPORTED_FIELD,
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
if (enumValue.isMap()) {
// map, the part after the enum name is the key of the map
attribute = enumValue.getName() + SUB_ATTRIBUTE_SEPARATOR + key.substring(firstSeparatorIndex + 1);
} else if (enumValue.getSubEntityAttributes().isEmpty()) {
// simple type without sub-attributes, so the sub-attribute is not allowed
throw new QueryException(UNSUPPORTED_FIELD, "Sub-attributes not supported for simple field " + enumValue);
} else {
final String[] subAttribute = key.substring(firstSeparatorIndex + 1).split(SUB_ATTRIBUTE_SPLIT_REGEX, 2);
attribute = enumValue.getName() + SUB_ATTRIBUTE_SEPARATOR + enumValue.getSubEntityAttributes().stream()
.filter(attr -> attr.equalsIgnoreCase(subAttribute[0])) // case normalized
.findFirst()
.map(attr -> subAttribute.length == 1 ? attr : attr + key.substring(firstSeparatorIndex + 1 + attr.length()))
.orElseThrow(() -> new QueryException(
UNSUPPORTED_FIELD,
String.format("The given search field {%s} has unsupported sub-attributes. Supported sub-attributes are %s",
key, enumValue.getSubEntityAttributes())));
}
}
return attribute;
}
private static class RsqlVisitor implements RSQLVisitor<Node, String> {
private final Function<String, String> keyResolver;
private RsqlVisitor(final UnaryOperator<String> keyResolver) {
this.keyResolver = keyResolver == null ? 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();
}
@SuppressWarnings("java:S3776") // java:S3776 - group in single method for easier read of whole logic
@Override
public Node visit(final ComparisonNode node, final String param) {
final String nodeSelector = node.getSelector();
final String key = keyResolver.apply(nodeSelector);
final Object value = toValue(node);
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(key, EQ, null, nodeSelector).or(new Comparison(key, EQ, "", nodeSelector));
}
return new Comparison(key, 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(key, LIKE, "*", nodeSelector).and(new Comparison(key, NE, "", nodeSelector));
}
return new Comparison(key, isLike(value) ? NOT_LIKE : NE, value, nodeSelector);
} else if (op == RSQLOperators.GREATER_THAN) {
return new Comparison(key, GT, value, nodeSelector);
} else if (op == RSQLOperators.GREATER_THAN_OR_EQUAL) {
return new Comparison(key, GTE, value, nodeSelector);
} else if (op == RSQLOperators.LESS_THAN) {
return new Comparison(key, LT, value, nodeSelector);
} else if (op == RSQLOperators.LESS_THAN_OR_EQUAL) {
return new Comparison(key, LTE, value, nodeSelector);
} else if (op == RSQLOperators.IN) {
return new Comparison(key, IN, value, nodeSelector);
} else if (op == RSQLOperators.NOT_IN) {
return new Comparison(key, NOT_IN, value, nodeSelector);
} else {
throw new IllegalArgumentException("Unsupported operator: " + node.getOperator());
}
}
private Object toValue(final ComparisonNode node) {
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().toList();
} else if (arguments.size() > 1) {
throw new IllegalArgumentException(
"Operator " + operator + " requires exactly one argument, but got: " + arguments.size());
} else {
return arguments.get(0);
}
}
}
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,62 @@
/**
* 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.ql.jpa;
import java.util.Map;
import java.util.Set;
import jakarta.persistence.CollectionTable;
import jakarta.persistence.Column;
import jakarta.persistence.ElementCollection;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.JoinTable;
import jakarta.persistence.ManyToMany;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.MapKeyColumn;
import lombok.Data;
import lombok.experimental.Accessors;
@Entity
@Data
@Accessors(chain = true)
class Root {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// singular attributes
// basic
private String strValue;
private int intValue;
// entity
@ManyToOne
private Sub subEntity;
// set searchable by key
@ManyToMany(targetEntity = Sub.class)
@JoinTable(
name = "subs",
joinColumns = { @JoinColumn(name = "root") },
inverseJoinColumns = { @JoinColumn(name = "subs") })
private Set<Sub> subSet;
// standard map
@ElementCollection
@CollectionTable(
name = "map",
joinColumns = { @JoinColumn(name = "root") })
@MapKeyColumn(name = "map_key", length = 128)
@Column(name = "map_value", length = 128)
private Map<String, String> subMap;
}

View File

@@ -0,0 +1,17 @@
/**
* 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.ql.jpa;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.repository.CrudRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface RootRepository extends CrudRepository<Root, Long>, JpaSpecificationExecutor<Root> {}

View File

@@ -0,0 +1,509 @@
/**
* 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.ql.jpa;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.StreamSupport;
import jakarta.persistence.EntityManager;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.hawkbit.ql.EntityMatcher;
import org.eclipse.hawkbit.ql.jpa.utils.QlToSql;
import org.eclipse.hawkbit.ql.rsql.RsqlParser;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.data.jpa.domain.Specification;
@SuppressWarnings("java:S5961") // complex check because the matter is very complex
@DataJpaTest(properties = { "spring.jpa.database=H2" }, excludeAutoConfiguration = { FlywayAutoConfiguration.class })
@EnableAutoConfiguration
@Slf4j
class SpecificationBuilderTest {
@Autowired
private SubSubRepository subSubRepository;
@Autowired
private SubRepository subRepository;
@Autowired
private RootRepository rootRepository;
@Autowired
private EntityManager entityManager;
private final SpecificationBuilder<Root> builder = new SpecificationBuilder<>(false);
@Test
void singularStringAttribute() {
final Root root1 = rootRepository.save(new Root().setStrValue("rootx"));
final Root root2 = rootRepository.save(new Root().setStrValue("rootx"));
final Root root3 = rootRepository.save(new Root().setStrValue("rooty"));
final Root root4 = rootRepository.save(new Root().setStrValue("rooty"));
final Root root5 = rootRepository.save(new Root()); // null
assertThat(filter("strValue==rootx")).hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("strValue==nostr")).isEmpty();
assertThat(filter("strValue=is=null")).hasSize(1).containsExactlyInAnyOrder(root5);
assertThat(filter("strValue!=rootx")).hasSize(3).containsExactlyInAnyOrder(root3, root4, root5);
assertThat(filter("strValue!=nostr")).hasSize(5);
assertThat(filter("strValue=not=null")).hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("strValue<rooty")).hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("strValue<=rooty")).hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("strValue>rootx")).hasSize(2).containsExactlyInAnyOrder(root3, root4);
assertThat(filter("strValue>=rootx")).hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("strValue=in=rootx")).hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("strValue=in=(rootx, rooty)")).hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("strValue=in=(rootz, roott)")).isEmpty();
assertThat(filter("strValue=out=rootx")).hasSize(3).containsExactlyInAnyOrder(root3, root4, root5);
assertThat(filter("strValue=out=(rootx, rooty)")).hasSize(1).containsExactlyInAnyOrder(root5);
// wildcard, like
assertThat(filter("strValue==root*")).hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("strValue==*tx")).hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("strValue!=root*")).hasSize(1).containsExactlyInAnyOrder(root5);
assertThat(filter("strValue!=*tx")).hasSize(3).containsExactlyInAnyOrder(root3, root4, root5);
assertThat(filter("strValue==*")).hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("strValue!=*")).hasSize(1).containsExactlyInAnyOrder(root5);
// null checks
assertThat(filter("strValue=is=null")).hasSize(1).containsExactlyInAnyOrder(root5);
assertThat(filter("strValue=not=null")).hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("strValue==*tx and strValue==rooty")).isEmpty();
assertThat(filter("strValue==*tx and strValue!=rootx")).isEmpty();
assertThat(filter("strValue==*tx and strValue==rootx")).hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("strValue==*tx or strValue==rooty")).hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("strValue==*tx or strValue!=rootx")).hasSize(5).containsExactlyInAnyOrder(root1, root2, root3, root4, root5);
assertThat(filter("strValue==*tx or strValue=is=null")).hasSize(3).containsExactlyInAnyOrder(root1, root2, root5);
}
@Test
void singularIntAttribute() {
final Root root1 = rootRepository.save(new Root().setIntValue(0));
final Root root2 = rootRepository.save(new Root().setIntValue(0));
final Root root3 = rootRepository.save(new Root().setIntValue(1));
final Root root4 = rootRepository.save(new Root().setIntValue(1));
assertThat(filter("intValue==0")).hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("intValue==2")).isEmpty();
assertThat(filter("intValue!=0")).hasSize(2).containsExactlyInAnyOrder(root3, root4);
assertThat(filter("intValue!=2")).hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("intValue<1")).hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("intValue<=1")).hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("intValue>0")).hasSize(2).containsExactlyInAnyOrder(root3, root4);
assertThat(filter("intValue>=0")).hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("intValue=in=0")).hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("intValue=in=(0, 1)")).hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("intValue=in=(2, 3)")).isEmpty();
assertThat(filter("intValue=out=0")).hasSize(2).containsExactlyInAnyOrder(root3, root4);
assertThat(filter("intValue=out=(0, 1)")).isEmpty();
assertThat(filter("intValue==0 and intValue==1")).isEmpty();
assertThat(filter("intValue==0 and intValue!=1")).hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("intValue==0 and intValue==0")).hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("intValue==0 or intValue==1")).hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("intValue==0 or intValue!=1")).hasSize(2).containsExactlyInAnyOrder(root1, root2);
}
@Test
void singularEntityAttribute() {
final Sub sub1 = subRepository.save(new Sub().setStrValue("subx").setIntValue(0));
final Sub sub2 = subRepository.save(new Sub().setStrValue("suby").setIntValue(1));
final Root root1 = rootRepository.save(new Root().setSubEntity(sub1));
final Root root2 = rootRepository.save(new Root().setSubEntity(sub1));
final Root root3 = rootRepository.save(new Root().setSubEntity(sub2));
final Root root4 = rootRepository.save(new Root().setSubEntity(sub2));
final Root root5 = rootRepository.save(new Root()); // no sub set
// by sub entity string
assertThat(filter("subEntity.strValue==subx")).hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("subEntity.strValue==nostr")).isEmpty();
// TODO / recheck - when missing entity shall it be included or not in != or =out=? - now it is
assertThat(filter("subEntity.strValue!=subx")).hasSize(3).containsExactlyInAnyOrder(root3, root4, root5);
assertThat(filter("subEntity.strValue!=nostr")).hasSize(5);
assertThat(filter("subEntity.strValue<suby")).hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("subEntity.strValue<=suby")).hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("subEntity.strValue>subx")).hasSize(2).containsExactlyInAnyOrder(root3, root4);
assertThat(filter("subEntity.strValue>=subx")).hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("subEntity.strValue=in=subx")).hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("subEntity.strValue=in=(subx, suby)")).hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("subEntity.strValue=in=(subZ, subT)")).isEmpty();
assertThat(filter("subEntity.strValue=out=subx")).hasSize(3).containsExactlyInAnyOrder(root3, root4, root5);
assertThat(filter("subEntity.strValue=out=(subx, suby)")).hasSize(1).containsExactlyInAnyOrder(root5);
// wildcard, like
assertThat(filter("subEntity.strValue==sub*")).hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("subEntity.strValue==*bx")).hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("subEntity.strValue!=sub*")).hasSize(1).containsExactlyInAnyOrder(root5);
assertThat(filter("subEntity.strValue!=*bx")).hasSize(3).containsExactlyInAnyOrder(root3, root4, root5);
assertThat(filter("subEntity.strValue==*")).hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("subEntity.strValue!=*")).hasSize(1).containsExactlyInAnyOrder(root5);
// null checks
assertThat(filter("subEntity.strValue=is=null")).hasSize(1).containsExactlyInAnyOrder(root5);
assertThat(filter("subEntity.strValue=not=null")).hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("subEntity.strValue==*bx and subEntity.strValue==suby")).isEmpty();
assertThat(filter("subEntity.strValue==*bx and subEntity.strValue!=subx")).isEmpty();
assertThat(filter("subEntity.strValue==*bx and subEntity.strValue==subx")).hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("subEntity.strValue==*bx or subEntity.strValue==suby"))
.hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("subEntity.strValue==*bx or subEntity.strValue!=subx"))
.hasSize(5).containsExactlyInAnyOrder(root1, root2, root3, root4, root5);
assertThat(filter("subEntity.strValue==*bx or subEntity.strValue=is=null")).hasSize(3)
.containsExactlyInAnyOrder(root1, root2, root5);
// by sub entity int
assertThat(filter("subEntity.intValue==0")).hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("subEntity.intValue==2")).isEmpty();
assertThat(filter("subEntity.intValue!=0")).hasSize(3).containsExactlyInAnyOrder(root3, root4, root5);
assertThat(filter("subEntity.intValue!=2")).hasSize(5);
assertThat(filter("subEntity.intValue<1")).hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("subEntity.intValue<=1")).hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("subEntity.intValue>0")).hasSize(2).containsExactlyInAnyOrder(root3, root4);
assertThat(filter("subEntity.intValue>=0")).hasSize(4);
assertThat(filter("subEntity.intValue=in=0")).hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("subEntity.intValue=in=(0, 1)")).hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("subEntity.intValue=in=(2, 3)")).isEmpty();
assertThat(filter("subEntity.intValue=out=0")).hasSize(3).containsExactlyInAnyOrder(root3, root4, root5);
assertThat(filter("subEntity.intValue=out=(0, 1)")).hasSize(1).containsExactlyInAnyOrder(root5);
assertThat(filter("subEntity.intValue==0 and subEntity.intValue==1")).isEmpty();
assertThat(filter("subEntity.intValue==0 and subEntity.intValue!=1")).hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("subEntity.intValue==0 and subEntity.intValue==0")).hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("subEntity.intValue==0 or subEntity.intValue==1")).hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("subEntity.intValue==0 or subEntity.intValue!=1")).hasSize(3).containsExactlyInAnyOrder(root1, root2, root5);
assertThat(filter("subEntity.strValue==subx and subEntity.intValue==1")).isEmpty();
assertThat(filter("subEntity.strValue==subx and subEntity.intValue!=1")).hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("subEntity.strValue==subx and subEntity.intValue==0")).hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("subEntity.strValue==subx or subEntity.intValue==1")).hasSize(4)
.containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("subEntity.strValue==subx or subEntity.intValue!=1")).hasSize(3).containsExactlyInAnyOrder(root1, root2, root5);
}
@Test
void pluralSubSetAttribute() {
final Sub sub1 = subRepository.save(new Sub().setStrValue("subx").setIntValue(0));
final Sub sub2 = subRepository.save(new Sub().setStrValue("suby").setIntValue(1));
final Sub sub3 = subRepository.save(new Sub().setStrValue("suby").setIntValue(0));
final Root root1 = rootRepository.save(new Root().setSubSet(Set.of(sub1)));
final Root root2 = rootRepository.save(new Root().setSubSet(Set.of(sub2)));
final Root root3 = rootRepository.save(new Root().setSubSet(Set.of(sub3)));
final Root root4 = rootRepository.save(new Root().setSubSet(Set.of(sub1, sub2)));
final Root root5 = rootRepository.save(new Root().setSubSet(Set.of(sub1, sub3)));
final Root root6 = rootRepository.save(new Root()); // no sub set
// by sub entity string
assertThat(filter("subSet.strValue==subx")).hasSize(3).containsExactlyInAnyOrder(root1, root4, root5);
assertThat(filter("subSet.strValue==nostr")).isEmpty();
assertThat(filter("subSet.strValue!=subx")).hasSize(3).containsExactlyInAnyOrder(root2, root3, root6);
assertThat(filter("subSet.strValue!=nostr")).hasSize(6);
assertThat(filter("subSet.strValue<suby")).hasSize(3).containsExactlyInAnyOrder(root1, root4, root5);
assertThat(filter("subSet.strValue<=suby")).hasSize(5).containsExactlyInAnyOrder(root1, root2, root3, root4, root5);
assertThat(filter("subSet.strValue>subx")).hasSize(4).containsExactlyInAnyOrder(root2, root3, root4, root5);
assertThat(filter("subSet.strValue>=subx")).hasSize(5).containsExactlyInAnyOrder(root1, root2, root3, root4, root5);
assertThat(filter("subSet.strValue=in=subx")).hasSize(3).containsExactlyInAnyOrder(root1, root4, root5);
assertThat(filter("subSet.strValue=in=(subx, suby)")).hasSize(5).containsExactlyInAnyOrder(root1, root2, root3, root4, root5);
assertThat(filter("subSet.strValue=in=(subZ, subT)")).isEmpty();
assertThat(filter("subSet.strValue=out=subx")).hasSize(3).containsExactlyInAnyOrder(root2, root3, root6);
assertThat(filter("subSet.strValue=out=(subx, suby)")).hasSize(1).containsExactlyInAnyOrder(root6);
// wildcard, like
assertThat(filter("subSet.strValue==sub*")).hasSize(5).containsExactlyInAnyOrder(root1, root2, root3, root4, root5);
assertThat(filter("subSet.strValue==*bx")).hasSize(3).containsExactlyInAnyOrder(root1, root4, root5);
assertThat(filter("subSet.strValue!=sub*")).hasSize(1).containsExactlyInAnyOrder(root6);
assertThat(filter("subSet.strValue!=*bx")).hasSize(3).containsExactlyInAnyOrder(root2, root3, root6);
assertThat(filter("subSet.strValue==*")).hasSize(5).containsExactlyInAnyOrder(root1, root2, root3, root4, root5);
assertThat(filter("subSet.strValue!=*")).hasSize(1).containsExactlyInAnyOrder(root6);
assertThat(filter("subSet.strValue==*bx and subSet.strValue==suby")).hasSize(2).containsExactlyInAnyOrder(root4, root5);
assertThat(filter("subSet.strValue==*bx and subSet.strValue!=subx")).isEmpty();
assertThat(filter("subSet.strValue==*bx and subSet.strValue!=suby")).hasSize(1).containsExactlyInAnyOrder(root1);
assertThat(filter("subSet.strValue==*bx or subSet.strValue==suby"))
.hasSize(5).containsExactlyInAnyOrder(root1, root2, root3, root4, root5);
assertThat(filter("subSet.strValue==*bx or subSet.strValue!=subx"))
.hasSize(6).containsExactlyInAnyOrder(root1, root2, root3, root4, root5, root6);
// by sub entity int
assertThat(filter("subSet.intValue==0")).hasSize(4).containsExactlyInAnyOrder(root1, root3, root4, root5);
assertThat(filter("subSet.intValue==2")).isEmpty();
// the legacy builders has different (and wrong semantic)
// G1 - has element != x
// G2 - has element != x or has no elements
// accompanying G3 it is (as it should be) semantic of set != x - has no element with value x (including nhas no elements)
assertThat(filter("subSet.intValue!=0")).hasSize(2).containsExactlyInAnyOrder(root2, root6);
assertThat(filter("subSet.intValue!=2")).hasSize(6);
assertThat(filter("subSet.intValue<1")).hasSize(4).containsExactlyInAnyOrder(root1, root3, root4, root5);
assertThat(filter("subSet.intValue<=1")).hasSize(5).containsExactlyInAnyOrder(root1, root2, root3, root4, root5);
assertThat(filter("subSet.intValue>0")).hasSize(2).containsExactlyInAnyOrder(root2, root4);
assertThat(filter("subSet.intValue>=0")).hasSize(5).containsExactlyInAnyOrder(root1, root2, root3, root4, root5);
assertThat(filter("subSet.intValue=in=0")).hasSize(4).containsExactlyInAnyOrder(root1, root3, root4, root5);
assertThat(filter("subSet.intValue=in=(0, 1)")).hasSize(5).containsExactlyInAnyOrder(root1, root2, root3, root4, root5);
assertThat(filter("subSet.intValue=in=(2, 3)")).isEmpty();
assertThat(filter("subSet.intValue=out=0")).hasSize(2).containsExactlyInAnyOrder(root2, root6);
assertThat(filter("subSet.intValue=out=(0, 1)")).hasSize(1).containsExactlyInAnyOrder(root6);
assertThat(filter("subSet.intValue==0 and subSet.intValue==0")).hasSize(4).containsExactlyInAnyOrder(root1, root3, root4, root5);
assertThat(filter("subSet.intValue==0 and subSet.intValue!=1")).hasSize(3).containsExactlyInAnyOrder(root1, root3, root5);
assertThat(filter("subSet.intValue==0 or subSet.intValue==1"))
.hasSize(5).containsExactlyInAnyOrder(root1, root2, root3, root4, root5);
assertThat(filter("subSet.intValue==0 or subSet.intValue!=1"))
.hasSize(5).containsExactlyInAnyOrder(root1, root3, root4, root5, root6);
assertThat(filter("subSet.intValue==0 and subSet.strValue==suby")).hasSize(3).containsExactlyInAnyOrder(root3, root4, root5);
assertThat(filter("subSet.intValue==0 and subSet.strValue!=subx")).hasSize(1).containsExactlyInAnyOrder(root3);
assertThat(filter("subSet.intValue==0 and subSet.strValue!=suby")).hasSize(1).containsExactlyInAnyOrder(root1);
assertThat(filter("subSet.intValue==0 or subSet.strValue==suby"))
.hasSize(5).containsExactlyInAnyOrder(root1, root2, root3, root4, root5);
assertThat(filter("subSet.intValue==0 or subSet.strValue!=subx"))
.hasSize(6).containsExactlyInAnyOrder(root1, root2, root3, root4, root5, root6);
}
@Test
void pluralSubMapAttribute() {
final Root root1 = rootRepository.save(new Root().setSubMap(Map.of("x", "rootx", "y", "rooty")));
final Root root2 = rootRepository.save(new Root().setSubMap(Map.of("x", "rootx", "y", "rootx")));
final Root root3 = rootRepository.save(new Root().setSubMap(Map.of("x", "rooty", "y", "rooty")));
final Root root4 = rootRepository.save(new Root().setSubMap(Map.of("x", "rooty", "y", "rootx")));
final Root root5 = rootRepository.save(new Root().setSubMap(Map.of("x", "rootx")));
final Root root6 = rootRepository.save(new Root()); // no sub map
assertThat(filter("subMap.x==rootx")).hasSize(3).containsExactlyInAnyOrder(root1, root2, root5);
assertThat(filter("subMap.x==nostr")).isEmpty();
// TODO / recheck - when missing entity shall it be included or not in != or =out=? - now it's not
assertThat(filter("subMap.x!=rootx")).hasSize(2).containsExactlyInAnyOrder(root3, root4);
assertThat(filter("subMap.x!=nostr")).hasSize(5).containsExactlyInAnyOrder(root1, root2, root3, root4, root5);
assertThat(filter("subMap.x<rooty")).hasSize(3).containsExactlyInAnyOrder(root1, root2, root5);
assertThat(filter("subMap.x<=rooty")).hasSize(5).containsExactlyInAnyOrder(root1, root2, root3, root4, root5);
assertThat(filter("subMap.x>rootx")).hasSize(2).containsExactlyInAnyOrder(root3, root4);
assertThat(filter("subMap.x>=rootx")).hasSize(5).containsExactlyInAnyOrder(root1, root2, root3, root4, root5);
assertThat(filter("subMap.x=in=rootx")).hasSize(3).containsExactlyInAnyOrder(root1, root2, root5);
assertThat(filter("subMap.x=in=(rootx, rooty)")).hasSize(5).containsExactlyInAnyOrder(root1, root2, root3, root4, root5);
assertThat(filter("subMap.x=in=(rootz, roott)")).isEmpty();
assertThat(filter("subMap.x=out=rootx")).hasSize(2).containsExactlyInAnyOrder(root3, root4);
assertThat(filter("subMap.x=out=(rootx, rooty)")).isEmpty();
// wildcard, like
assertThat(filter("subMap.x==root*")).hasSize(5).containsExactlyInAnyOrder(root1, root2, root3, root4, root5);
assertThat(filter("subMap.x==*tx")).hasSize(3).containsExactlyInAnyOrder(root1, root2, root5);
assertThat(filter("subMap.x!=root*")).isEmpty();
assertThat(filter("subMap.x!=*tx")).hasSize(2).containsExactlyInAnyOrder(root3, root4);
assertThat(filter("subMap.x==*")).hasSize(5).containsExactlyInAnyOrder(root1, root2, root3, root4, root5);
assertThat(filter("subMap.x!=*")).isEmpty();
// null checks
assertThat(filter("subMap.x=is=null")).hasSize(1).containsExactlyInAnyOrder(root6);
assertThat(filter("subMap.x=not=null")).hasSize(5).containsExactlyInAnyOrder(root1, root2, root3, root4, root5);
assertThat(filter("subMap.x==*tx and subMap.y==rooty")).hasSize(1).containsExactlyInAnyOrder(root1);
assertThat(filter("subMap.x==*tx and subMap.y!=rootx")).hasSize(1).containsExactlyInAnyOrder(root1);
assertThat(filter("subMap.x==*tx or subMap.x==rooty"))
.hasSize(5).containsExactlyInAnyOrder(root1, root2, root3, root4, root5);
assertThat(filter("subMap.x==*tx or subMap.x!=rootx"))
.hasSize(5).containsExactlyInAnyOrder(root1, root2, root3, root4, root5);
}
@Test
void singularEntitySubSubAttribute() {
final SubSub subSub1 = subSubRepository.save(new SubSub().setStrValue("subx").setIntValue(0));
final SubSub subSub2 = subSubRepository.save(new SubSub().setStrValue("suby").setIntValue(1));
final Sub sub1 = subRepository.save(new Sub().setSubSub(subSub1));
final Sub sub2 = subRepository.save(new Sub().setSubSub(subSub2));
final Root root1 = rootRepository.save(new Root().setSubEntity(sub1));
final Root root2 = rootRepository.save(new Root().setSubEntity(sub1));
final Root root3 = rootRepository.save(new Root().setSubEntity(sub2));
final Root root4 = rootRepository.save(new Root().setSubEntity(sub2));
final Root root5 = rootRepository.save(new Root()); // no sub set
// by sub entity string
assertThat(filter("subEntity.subSub.strValue==subx")).hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("subEntity.subSub.strValue==nostr")).isEmpty();
// TODO / recheck - when missing entity shall it be included or not in != or =out=? - now it is
assertThat(filter("subEntity.subSub.strValue!=subx")).hasSize(3).containsExactlyInAnyOrder(root3, root4, root5);
assertThat(filter("subEntity.subSub.strValue!=nostr")).hasSize(5);
assertThat(filter("subEntity.subSub.strValue<suby")).hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("subEntity.subSub.strValue<=suby")).hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("subEntity.subSub.strValue>subx")).hasSize(2).containsExactlyInAnyOrder(root3, root4);
assertThat(filter("subEntity.subSub.strValue>=subx")).hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("subEntity.subSub.strValue=in=subx")).hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("subEntity.subSub.strValue=in=(subx, suby)")).hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("subEntity.subSub.strValue=in=(subZ, subT)")).isEmpty();
assertThat(filter("subEntity.subSub.strValue=out=subx")).hasSize(3).containsExactlyInAnyOrder(root3, root4, root5);
assertThat(filter("subEntity.subSub.strValue=out=(subx, suby)")).hasSize(1).containsExactlyInAnyOrder(root5);
// wildcard, like
assertThat(filter("subEntity.subSub.strValue==sub*")).hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("subEntity.subSub.strValue==*bx")).hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("subEntity.subSub.strValue!=sub*")).hasSize(1).containsExactlyInAnyOrder(root5);
assertThat(filter("subEntity.subSub.strValue!=*bx")).hasSize(3).containsExactlyInAnyOrder(root3, root4, root5);
assertThat(filter("subEntity.subSub.strValue==*")).hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("subEntity.subSub.strValue!=*")).hasSize(1).containsExactlyInAnyOrder(root5);
assertThat(filter("subEntity.subSub.strValue=is=null")).hasSize(1).containsExactlyInAnyOrder(root5);
assertThat(filter("subEntity.subSub.strValue=not=null")).hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("subEntity.subSub.strValue==*bx and subEntity.subSub.strValue==suby")).isEmpty();
assertThat(filter("subEntity.subSub.strValue==*bx and subEntity.subSub.strValue!=subx")).isEmpty();
assertThat(filter("subEntity.subSub.strValue==*bx and subEntity.subSub.strValue==subx"))
.hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("subEntity.subSub.strValue==*bx or subEntity.subSub.strValue==suby"))
.hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("subEntity.subSub.strValue==*bx or subEntity.subSub.strValue!=subx"))
.hasSize(5).containsExactlyInAnyOrder(root1, root2, root3, root4, root5);
assertThat(filter("subEntity.subSub.strValue==*bx or subEntity.subSub.strValue=is=null"))
.hasSize(3).containsExactlyInAnyOrder(root1, root2, root5);
// by sub entity int
assertThat(filter("subEntity.subSub.intValue==0")).hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("subEntity.subSub.intValue==2")).isEmpty();
assertThat(filter("subEntity.subSub.intValue!=0")).hasSize(3).containsExactlyInAnyOrder(root3, root4, root5);
assertThat(filter("subEntity.subSub.intValue!=2")).hasSize(5);
assertThat(filter("subEntity.subSub.intValue<1")).hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("subEntity.subSub.intValue<=1")).hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("subEntity.subSub.intValue>0")).hasSize(2).containsExactlyInAnyOrder(root3, root4);
assertThat(filter("subEntity.subSub.intValue>=0")).hasSize(4);
assertThat(filter("subEntity.subSub.intValue=in=0")).hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("subEntity.subSub.intValue=in=(0, 1)")).hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("subEntity.subSub.intValue=in=(2, 3)")).isEmpty();
assertThat(filter("subEntity.subSub.intValue=out=0")).hasSize(3).containsExactlyInAnyOrder(root3, root4, root5);
assertThat(filter("subEntity.subSub.intValue=out=(0, 1)")).hasSize(1).containsExactlyInAnyOrder(root5);
assertThat(filter("subEntity.subSub.intValue==0 and subEntity.subSub.intValue==1")).isEmpty();
assertThat(filter("subEntity.subSub.intValue==0 and subEntity.subSub.intValue!=1")).hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("subEntity.subSub.intValue==0 and subEntity.subSub.intValue==0")).hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("subEntity.subSub.intValue==0 or subEntity.subSub.intValue==1")).hasSize(4)
.containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("subEntity.subSub.intValue==0 or subEntity.subSub.intValue!=1"))
.hasSize(3).containsExactlyInAnyOrder(root1, root2, root5);
assertThat(filter("subEntity.subSub.strValue==subx and subEntity.subSub.intValue==1")).isEmpty();
assertThat(filter("subEntity.subSub.strValue==subx and subEntity.subSub.intValue!=1")).hasSize(2)
.containsExactlyInAnyOrder(root1, root2);
assertThat(filter("subEntity.subSub.strValue==subx and subEntity.subSub.intValue==0"))
.hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("subEntity.subSub.strValue==subx or subEntity.subSub.intValue==1"))
.hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("subEntity.subSub.strValue==subx or subEntity.subSub.intValue!=1"))
.hasSize(3).containsExactlyInAnyOrder(root1, root2, root5);
}
@Test
void deapSearchSubSubSubSubAttribute() {
final SubSub subSubSubSub1 = subSubRepository.save(new SubSub().setStrValue("subx").setIntValue(0));
final SubSub subSubSubSub2 = subSubRepository.save(new SubSub().setStrValue("suby").setIntValue(1));
final SubSub subSub1 = subSubRepository.save(new SubSub().setStrValue("subx").setIntValue(0).setSubSub(subSubSubSub1));
final SubSub subSub2 = subSubRepository.save(new SubSub().setStrValue("suby").setIntValue(1).setSubSub(subSubSubSub2));
final Sub sub1 = subRepository.save(new Sub().setSubSub(subSub1));
final Sub sub2 = subRepository.save(new Sub().setSubSub(subSub2));
final Root root1 = rootRepository.save(new Root().setSubEntity(sub1));
final Root root2 = rootRepository.save(new Root().setSubEntity(sub1));
final Root root3 = rootRepository.save(new Root().setSubEntity(sub2));
final Root root4 = rootRepository.save(new Root().setSubEntity(sub2));
final Root root5 = rootRepository.save(new Root()); // no sub set
// by sub entity string
assertThat(filter("subEntity.subSub.subSub.strValue==subx")).hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("subEntity.subSub.subSub.strValue==nostr")).isEmpty();
// TODO / recheck - when missing entity shall it be included or not in != or =out=? - now it is
assertThat(filter("subEntity.subSub.subSub.strValue!=subx")).hasSize(3).containsExactlyInAnyOrder(root3, root4, root5);
assertThat(filter("subEntity.subSub.subSub.strValue!=nostr")).hasSize(5);
assertThat(filter("subEntity.subSub.subSub.strValue<suby")).hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("subEntity.subSub.subSub.strValue<=suby")).hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("subEntity.subSub.subSub.strValue>subx")).hasSize(2).containsExactlyInAnyOrder(root3, root4);
assertThat(filter("subEntity.subSub.subSub.strValue>=subx")).hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("subEntity.subSub.subSub.strValue=in=subx")).hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("subEntity.subSub.subSub.strValue=in=(subx, suby)")).hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("subEntity.subSub.subSub.strValue=in=(subZ, subT)")).isEmpty();
assertThat(filter("subEntity.subSub.subSub.strValue=out=subx")).hasSize(3).containsExactlyInAnyOrder(root3, root4, root5);
assertThat(filter("subEntity.subSub.subSub.strValue=out=(subx, suby)")).hasSize(1).containsExactlyInAnyOrder(root5);
// wildcard, like
assertThat(filter("subEntity.subSub.subSub.strValue==sub*")).hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("subEntity.subSub.subSub.strValue==*bx")).hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("subEntity.subSub.subSub.strValue!=sub*")).hasSize(1).containsExactlyInAnyOrder(root5);
assertThat(filter("subEntity.subSub.subSub.strValue!=*bx")).hasSize(3).containsExactlyInAnyOrder(root3, root4, root5);
assertThat(filter("subEntity.subSub.subSub.strValue==*")).hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("subEntity.subSub.subSub.strValue!=*")).hasSize(1).containsExactlyInAnyOrder(root5);
assertThat(filter("subEntity.subSub.subSub.strValue=is=null")).hasSize(1).containsExactlyInAnyOrder(root5);
assertThat(filter("subEntity.subSub.subSub.strValue==*bx and subEntity.subSub.strValue==suby")).isEmpty();
assertThat(filter("subEntity.subSub.subSub.strValue==*bx and subEntity.subSub.strValue!=subx")).isEmpty();
assertThat(filter("subEntity.subSub.subSub.strValue==*bx and subEntity.subSub.strValue==subx"))
.hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("subEntity.subSub.subSub.strValue==*bx or subEntity.subSub.strValue==suby"))
.hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("subEntity.subSub.subSub.strValue==*bx or subEntity.subSub.strValue!=subx"))
.hasSize(5).containsExactlyInAnyOrder(root1, root2, root3, root4, root5);
assertThat(filter("subEntity.subSub.subSub.strValue==*bx or subEntity.subSub.strValue=is=null"))
.hasSize(3).containsExactlyInAnyOrder(root1, root2, root5);
// by sub entity int
assertThat(filter("subEntity.subSub.subSub.intValue==0")).hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("subEntity.subSub.subSub.intValue==2")).isEmpty();
assertThat(filter("subEntity.subSub.subSub.intValue!=0")).hasSize(3).containsExactlyInAnyOrder(root3, root4, root5);
assertThat(filter("subEntity.subSub.subSub.intValue!=2")).hasSize(5);
assertThat(filter("subEntity.subSub.subSub.intValue<1")).hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("subEntity.subSub.subSub.intValue<=1")).hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("subEntity.subSub.subSub.intValue>0")).hasSize(2).containsExactlyInAnyOrder(root3, root4);
assertThat(filter("subEntity.subSub.subSub.intValue>=0")).hasSize(4);
assertThat(filter("subEntity.subSub.subSub.intValue=in=0")).hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("subEntity.subSub.subSub.intValue=in=(0, 1)")).hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("subEntity.subSub.subSub.intValue=in=(2, 3)")).isEmpty();
assertThat(filter("subEntity.subSub.subSub.intValue=out=0")).hasSize(3).containsExactlyInAnyOrder(root3, root4, root5);
assertThat(filter("subEntity.subSub.subSub.intValue=out=(0, 1)")).hasSize(1).containsExactlyInAnyOrder(root5);
assertThat(filter("subEntity.subSub.subSub.intValue==0 and subEntity.subSub.intValue==1")).isEmpty();
assertThat(filter("subEntity.subSub.subSub.intValue==0 and subEntity.subSub.intValue!=1")).hasSize(2)
.containsExactlyInAnyOrder(root1, root2);
assertThat(filter("subEntity.subSub.subSub.intValue==0 and subEntity.subSub.intValue==0")).hasSize(2)
.containsExactlyInAnyOrder(root1, root2);
assertThat(filter("subEntity.subSub.subSub.intValue==0 or subEntity.subSub.intValue==1")).hasSize(4)
.containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("subEntity.subSub.subSub.intValue==0 or subEntity.subSub.subSub.intValue!=1"))
.hasSize(3).containsExactlyInAnyOrder(root1, root2, root5);
assertThat(filter("subEntity.subSub.subSub.strValue==subx and subEntity.subSub.intValue==1")).isEmpty();
assertThat(filter("subEntity.subSub.subSub.strValue==subx and subEntity.subSub.intValue!=1")).hasSize(2)
.containsExactlyInAnyOrder(root1, root2);
assertThat(filter("subEntity.subSub.subSub.strValue==subx and subEntity.subSub.intValue==0"))
.hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("subEntity.subSub.subSub.strValue==subx or subEntity.subSub.intValue==1"))
.hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("subEntity.subSub.subSub.strValue==subx or subEntity.subSub.intValue!=1"))
.hasSize(3).containsExactlyInAnyOrder(root1, root2, root5);
}
private List<Root> filter(final String rsql) {
// reference / auto filter (using elements and reflection)
final EntityMatcher matcher = EntityMatcher.of(RsqlParser.parse(rsql));
final List<Root> refResult = StreamSupport.stream(rootRepository.findAll().spliterator(), false).filter(matcher::match).toList();
final List<Root> result = rootRepository.findAll(getSpecification(rsql));
// auto check with reference result
try {
assertThat(result).containsExactlyInAnyOrder(refResult.toArray(Root[]::new));
} catch (final AssertionError e) {
log.error("Fail to get expected result for RSQL: {} with SQL query: {}",
rsql, new QlToSql(entityManager).toSQL(Root.class, null, rsql), e);
throw e;
}
return result;
}
protected Specification<Root> getSpecification(final String rsql) {
return builder.specification(RsqlParser.parse(rsql));
}
@SpringBootConfiguration
static class Config {}
}

View File

@@ -0,0 +1,37 @@
/**
* 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.ql.jpa;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.ManyToOne;
import lombok.Data;
import lombok.experimental.Accessors;
@Entity
@Data
@Accessors(chain = true)
class Sub {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// singular attributes
// basic
private String strValue;
private int intValue;
// entity
@ManyToOne
private SubSub subSub;
}

View File

@@ -0,0 +1,17 @@
/**
* 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.ql.jpa;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.repository.CrudRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface SubRepository extends CrudRepository<Sub, Long>, JpaSpecificationExecutor<Sub> {}

View File

@@ -0,0 +1,37 @@
/**
* 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.ql.jpa;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.ManyToOne;
import lombok.Data;
import lombok.experimental.Accessors;
@Entity
@Data
@Accessors(chain = true)
class SubSub {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// singular attributes
// basic
private String strValue;
private int intValue;
@ManyToOne
private SubSub subSub;
}

View File

@@ -0,0 +1,17 @@
/**
* 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.ql.jpa;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.repository.CrudRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface SubSubRepository extends CrudRepository<SubSub, Long>, JpaSpecificationExecutor<SubSub> {}