Fix != and =out= for maps in G2 RSQL to Specification (#2426)

+ add initial draft of Standalone RSQL test
+ provide option to override Hibernate / Eclipselink configuration via standard spring environment properties

Signed-off-by: Avgustin Marinov <Avgustin.Marinov@bosch.com>
This commit is contained in:
Avgustin Marinov
2025-06-04 16:30:33 +03:00
committed by GitHub
parent d3341f7c73
commit 23fa4cdd56
15 changed files with 716 additions and 57 deletions

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.repository.jpa.rsql;
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 (Utils.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

@@ -9,13 +9,15 @@
*/
package org.eclipse.hawkbit.repository.jpa.rsql;
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.repository.RsqlQueryField;
import org.eclipse.hawkbit.repository.jpa.Utils;
import org.eclipse.hawkbit.repository.rsql.RsqlConfigHolder;
import org.eclipse.hawkbit.repository.rsql.RsqlConfigHolder.RsqlToSpecBuilder;
import org.springframework.orm.jpa.vendor.Database;
@@ -24,16 +26,34 @@ public class RSQLToSQL {
private static final Database DATABASE = Database.H2;
private final EntityManager entityManager;
private final boolean isEclipselink;
public RSQLToSQL(final EntityManager entityManager) {
this.entityManager = entityManager;
isEclipselink = entityManager.getProperties().keySet().stream().anyMatch(key -> key.startsWith("eclipselink."));
}
public <T, A extends Enum<A> & RsqlQueryField> String toSQL(
final Class<T> domainClass, final Class<A> fieldsClass, final String rsql, final RsqlToSpecBuilder rsqlToSpecBuilder) {
final CriteriaQuery<T> query = createQuery(domainClass, fieldsClass, rsql, rsqlToSpecBuilder);
final TypedQuery<?> typedQuery = entityManager.createQuery(query);
return Utils.toSql(typedQuery);
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> & RsqlQueryField> CriteriaQuery<T> createQuery(

View File

@@ -46,10 +46,11 @@ class RSQLToSQLTest {
private RSQLToSQL rsqlToSQL;
@Test
void p() {
print(JpaTarget.class, TargetFields.class, TargetFields.TAG.name() + "==''");
print(JpaTarget.class, TargetFields.class, TargetFields.TAG.name() + "!=''");
void printPG() {
printFrom(JpaTarget.class, TargetFields.class, "tag!=TAG1 and tag==TAG2");
printFrom(JpaTarget.class, TargetFields.class, "tag==TAG1 and tag!=TAG2");
}
@Test
void print() {
print(JpaTarget.class, TargetFields.class, "tag==tag1 and tag==tag2");
@@ -97,9 +98,9 @@ class RSQLToSQLTest {
}
@Test
void printPG() {
printFrom(JpaTarget.class, TargetFields.class, "tag!=TAG1 and tag==TAG2");
printFrom(JpaTarget.class, TargetFields.class, "tag==TAG1 and tag!=TAG2");
void printEmpty() {
print(JpaTarget.class, TargetFields.class, TargetFields.TAG.name() + "==''");
print(JpaTarget.class, TargetFields.class, TargetFields.TAG.name() + "!=''");
}
private static String from(final String sql) {

View File

@@ -0,0 +1,222 @@
/**
* Copyright (c) 2025 Contributors to the Eclipse Foundation
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.repository.jpa.rsql.sa;
import static org.eclipse.hawkbit.repository.jpa.rsql.Node.Comparison.Operator.EQ;
import static org.eclipse.hawkbit.repository.jpa.rsql.Node.Comparison.Operator.GT;
import static org.eclipse.hawkbit.repository.jpa.rsql.Node.Comparison.Operator.GTE;
import static org.eclipse.hawkbit.repository.jpa.rsql.Node.Comparison.Operator.IN;
import static org.eclipse.hawkbit.repository.jpa.rsql.Node.Comparison.Operator.LIKE;
import static org.eclipse.hawkbit.repository.jpa.rsql.Node.Comparison.Operator.LT;
import static org.eclipse.hawkbit.repository.jpa.rsql.Node.Comparison.Operator.LTE;
import static org.eclipse.hawkbit.repository.jpa.rsql.Node.Comparison.Operator.NE;
import static org.eclipse.hawkbit.repository.jpa.rsql.Node.Comparison.Operator.NOT_IN;
import static org.eclipse.hawkbit.repository.jpa.rsql.Node.Comparison.Operator.NOT_LIKE;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.ParameterizedType;
import java.util.Arrays;
import java.util.Collection;
import java.util.Map;
import java.util.Objects;
import java.util.function.BiFunction;
import org.eclipse.hawkbit.repository.jpa.rsql.Node;
import org.eclipse.hawkbit.repository.jpa.rsql.Node.Comparison.Operator;
import org.eclipse.hawkbit.repository.jpa.rsql.RsqlParser;
/**
* Provides matching reference for if an object matches a {@link Node}.
*/
class ReferenceMatcher {
private final Node root;
private ReferenceMatcher(final Node root) {
this.root = root;
}
static ReferenceMatcher of(final Node root) {
return new ReferenceMatcher(root);
}
static ReferenceMatcher ofRsql(final String rsql) {
return of(RsqlParser.parse(rsql));
}
<T> boolean match(final T t) {
return match(t, root);
}
private static <T> boolean match(final T t, final Node node) {
if (node instanceof Node.Comparison comparison) {
final String[] split = comparison.getKey().split("\\.", 2);
try {
final Method fieldGetter = getGetter(t.getClass(), split[0]);
fieldGetter.setAccessible(true);
final Object fieldValue = fieldGetter.invoke(t);
final Operator op = comparison.getOp();
if (Map.class.isAssignableFrom(fieldGetter.getReturnType())) {
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 compare(
fieldValue == null ? null : ((Map<?, ?>) fieldValue).get(split[1]),
op,
map(
comparison.getValue(),
(Class<?>) ((ParameterizedType) fieldGetter.getGenericReturnType()).getActualTypeArguments()[1]));
} else if (Collection.class.isAssignableFrom(fieldGetter.getReturnType())) { // Set / List
final Object value;
final BiFunction<Object, Operator, Boolean> compare;
if (split.length == 1) {
value = map(comparison.getValue(), fieldGetter.getReturnType());
compare = (e, operator) -> compare(e, operator, value);
} else {
final Method valueGetter = getGetter(
(Class<?>) ((ParameterizedType) fieldGetter.getGenericReturnType()).getActualTypeArguments()[0], split[1]);
value = map(comparison.getValue(), valueGetter.getReturnType());
compare = (e, operator) -> {
try {
return compare(map(e == null ? null : valueGetter.invoke(e), valueGetter.getReturnType()), 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.apply(e, op));
case NE, NOT_IN, NOT_LIKE -> set == null
? true
: set.stream().noneMatch(e -> compare.apply(e, op == NE ? EQ : op == NOT_IN ? IN : LIKE));
};
} else {
if (split.length == 1) {
return compare(fieldValue, op, map(comparison.getValue(), fieldGetter.getReturnType()));
} else {
final Method valueGetter = getGetter(fieldGetter.getReturnType(), split[1]);
return compare(fieldValue == null ? null : valueGetter.invoke(fieldValue), op,
map(comparison.getValue(), valueGetter.getReturnType()));
}
}
} 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 static <T> Method getGetter(final Class<T> t, final String fieldName) throws NoSuchMethodException {
final String getterLowercase = "get" + fieldName.toLowerCase();
return Arrays.stream(t.getMethods())
.filter(method -> getterLowercase.equals(method.getName().toLowerCase()))
.findFirst()
.map(method -> {
method.setAccessible(true);
return method;
}).orElseThrow(() -> new NoSuchMethodException("No getter found for field: " + fieldName + " in class: " + t.getName()));
}
@SuppressWarnings({ "unchecked", "rawtypes" })
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 o1, final Operator op, final Object o2) {
if ((o1 == null || o2 == null) && // null is not comparable!
(op == GT || op == GTE || op == LT || op == LTE)) {
return false;
}
return switch (op) {
case EQ -> Objects.equals(o1, o2);
case NE -> !Objects.equals(o1, o2);
case GT -> compare(o1, o2) > 0;
case GTE -> compare(o1, o2) >= 0;
case LT -> compare(o1, o2) < 0;
case LTE -> compare(o1, o2) <= 0;
case IN -> in(o1, o2);
case NOT_IN -> !in(o1, o2);
case LIKE -> like(o2, o1);
case NOT_LIKE -> !like(o2, o1);
};
}
@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()));
}
}
}

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.repository.jpa.rsql.sa;
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,18 @@
/**
* Copyright (c) 2025 Contributors to the Eclipse Foundation
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.repository.jpa.rsql.sa;
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,251 @@
/**
* Copyright (c) 2025 Contributors to the Eclipse Foundation
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.repository.jpa.rsql.sa;
import static org.assertj.core.api.Assertions.assertThat;
import static org.eclipse.hawkbit.repository.rsql.RsqlConfigHolder.RsqlToSpecBuilder.G3;
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.repository.jpa.rsql.RSQLToSQL;
import org.eclipse.hawkbit.repository.jpa.rsql.RsqlParser;
import org.eclipse.hawkbit.repository.jpa.rsql.SpecificationBuilder;
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.orm.jpa.vendor.Database;
@SuppressWarnings("java:S5961") // complex check because the matter is very complex
@DataJpaTest(properties = {
"logging.level.org.eclipse.hawkbit.repository.jpa.rsql=DEBUG"
}, excludeAutoConfiguration = { FlywayAutoConfiguration.class } )
@EnableAutoConfiguration
@Slf4j
class SpecificationBuilderTest {
private static final Database DATABASE = Database.H2;
@Autowired
private SubRepository subRepository;
@Autowired
private RootRepository rootRepository;
@Autowired
private EntityManager entityManager;
private final SpecificationBuilder<Root> builder = new SpecificationBuilder<>(null, false, DATABASE);
@Test
void singularStringAttribute() {
final Root root = 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(root, 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(root, root2, root3, root4);
assertThat(filter("strValue<rootY")).hasSize(2).containsExactlyInAnyOrder(root, root2);
assertThat(filter("strValue<=rootY")).hasSize(4).containsExactlyInAnyOrder(root, root2, root3, root4);
assertThat(filter("strValue>rootX")).hasSize(2).containsExactlyInAnyOrder(root3, root4);
assertThat(filter("strValue>=rootX")).hasSize(4);
assertThat(filter("strValue=in=rootX")).hasSize(2).containsExactlyInAnyOrder(root, root2);
assertThat(filter("strValue=in=(rootX, rootY)")).hasSize(4).containsExactlyInAnyOrder(root, 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(root, root2, root3, root4);
assertThat(filter("strValue==*tX")).hasSize(2).containsExactlyInAnyOrder(root, root2);
assertThat(filter("strValue!=root*")).hasSize(1).containsExactlyInAnyOrder(root5);
assertThat(filter("strValue!=*tX")).hasSize(3).containsExactlyInAnyOrder(root3, root4, root5);
}
@Test
void singularIntAttribute() {
final Root root = 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(root, root2);
assertThat(filter("intValue==2")).isEmpty();
assertThat(filter("intValue!=0")).hasSize(2).containsExactlyInAnyOrder(root3, root4);
assertThat(filter("intValue!=2")).hasSize(4).containsExactlyInAnyOrder(root, root2, root3, root4);
assertThat(filter("intValue<1")).hasSize(2).containsExactlyInAnyOrder(root, root2);
assertThat(filter("intValue<=1")).hasSize(4).containsExactlyInAnyOrder(root, root2, root3, root4);
assertThat(filter("intValue>0")).hasSize(2).containsExactlyInAnyOrder(root3, root4);
assertThat(filter("intValue>=0")).hasSize(4).containsExactlyInAnyOrder(root, root2, root3, root4);
assertThat(filter("intValue=in=0")).hasSize(2).containsExactlyInAnyOrder(root, root2);
assertThat(filter("intValue=in=(0, 1)")).hasSize(4).containsExactlyInAnyOrder(root, 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();
}
@Test
void singularEntityAttribute() {
final Sub sub = subRepository.save(new Sub().setStrValue("subX").setIntValue(0));
final Sub sub2 = subRepository.save(new Sub().setStrValue("subY").setIntValue(1));
final Root root = rootRepository.save(new Root().setSubEntity(sub));
final Root root2 = rootRepository.save(new Root().setSubEntity(sub));
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(root, 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(root, root2);
assertThat(filter("subEntity.strValue<=subY")).hasSize(4).containsExactlyInAnyOrder(root, root2, root3, root4);
assertThat(filter("subEntity.strValue>subX")).hasSize(2).containsExactlyInAnyOrder(root3, root4);
assertThat(filter("subEntity.strValue>=subX")).hasSize(4).containsExactlyInAnyOrder(root, root2, root3, root4);
assertThat(filter("subEntity.strValue=in=subX")).hasSize(2).containsExactlyInAnyOrder(root, root2);
assertThat(filter("subEntity.strValue=in=(subX, subY)")).hasSize(4).containsExactlyInAnyOrder(root, 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(root, root2, root3, root4);
assertThat(filter("subEntity.strValue==*bX")).hasSize(2).containsExactlyInAnyOrder(root, root2);
assertThat(filter("subEntity.strValue!=sub*")).hasSize(1).containsExactlyInAnyOrder(root5);
assertThat(filter("subEntity.strValue!=*bX")).hasSize(3).containsExactlyInAnyOrder(root3, root4, root5);
// by sub entity int
assertThat(filter("subEntity.intValue==0")).hasSize(2).containsExactlyInAnyOrder(root, 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(root, root2);
assertThat(filter("subEntity.intValue<=1")).hasSize(4).containsExactlyInAnyOrder(root, 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(root, root2);
assertThat(filter("subEntity.intValue=in=(0, 1)")).hasSize(4).containsExactlyInAnyOrder(root, 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);
}
@Test
void pluralSubSetAttribute() {
final Sub sub = 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 root = rootRepository.save(new Root().setSubSet(Set.of(sub)));
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(sub, sub2)));
final Root root5 = rootRepository.save(new Root().setSubSet(Set.of(sub, sub3)));
final Root root6 = rootRepository.save(new Root()); // no sub set
// by sub entity string
assertThat(filter("subSet.strValue==subX")).hasSize(3).containsExactlyInAnyOrder(root, 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(root, root4, root5);
assertThat(filter("subSet.strValue<=subY")).hasSize(5).containsExactlyInAnyOrder(root, root2, root3, root4, root5);
assertThat(filter("subSet.strValue>subX")).hasSize(4).containsExactlyInAnyOrder(root2, root3, root4, root5);
assertThat(filter("subSet.strValue>=subX")).hasSize(5).containsExactlyInAnyOrder(root, root2, root3, root4, root5);
assertThat(filter("subSet.strValue=in=subX")).hasSize(3).containsExactlyInAnyOrder(root, root4, root5);
assertThat(filter("subSet.strValue=in=(subX, subY)"))
.hasSize(5).containsExactlyInAnyOrder(root, 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(root, root2, root3, root4, root5);
assertThat(filter("subSet.strValue==*bX")).hasSize(3).containsExactlyInAnyOrder(root, root4, root5);
assertThat(filter("subSet.strValue!=sub*")).hasSize(1).containsExactlyInAnyOrder(root6);
assertThat(filter("subSet.strValue!=*bX")).hasSize(3).containsExactlyInAnyOrder(root2, root3, root6);
// by sub entity int
assertThat(filter("subSet.intValue==0")).hasSize(4).containsExactlyInAnyOrder(root, root3, root4, root5);
assertThat(filter("subSet.intValue==2")).isEmpty();
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(root, root3, root4, root5);
assertThat(filter("subSet.intValue<=1")).hasSize(5).containsExactlyInAnyOrder(root, root2, root3, root4, root5);
assertThat(filter("subSet.intValue>0")).hasSize(2).containsExactlyInAnyOrder(root2, root4);
assertThat(filter("subSet.intValue>=0")).hasSize(5).containsExactlyInAnyOrder(root, root2, root3, root4, root5);
assertThat(filter("subSet.intValue=in=0")).hasSize(4).containsExactlyInAnyOrder(root, root3, root4, root5);
assertThat(filter("subSet.intValue=in=(0, 1)")).hasSize(5).containsExactlyInAnyOrder(root, 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);
}
@Test
void pluralSubMapAttribute() {
final Root root = 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(root, 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(root, root2, root3, root4, root5);
assertThat(filter("subMap.x<rootY")).hasSize(3).containsExactlyInAnyOrder(root, root2, root5);
assertThat(filter("subMap.x<=rootY")).hasSize(5).containsExactlyInAnyOrder(root, root2, root3, root4, root5);
assertThat(filter("subMap.x>rootX")).hasSize(2).containsExactlyInAnyOrder(root3, root4);
assertThat(filter("subMap.x>=rootX")).hasSize(5).containsExactlyInAnyOrder(root, root2, root3, root4, root5);
assertThat(filter("subMap.x=in=rootX")).hasSize(3).containsExactlyInAnyOrder(root, root2, root5);
assertThat(filter("subMap.x=in=(rootX, rootY)")).hasSize(5).containsExactlyInAnyOrder(root, 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(root, root2, root3, root4, root5);
assertThat(filter("subMap.x==*tX")).hasSize(3).containsExactlyInAnyOrder(root, root2, root5);
assertThat(filter("subMap.x!=root*")).isEmpty();
assertThat(filter("subMap.x!=*tX")).hasSize(2).containsExactlyInAnyOrder(root3, root4);
}
private List<Root> filter(final String rsql) {
// reference / auto filter (using elements and reflection)
final ReferenceMatcher matcher = ReferenceMatcher.ofRsql(rsql);
final List<Root> refResult = StreamSupport.stream(rootRepository.findAll().spliterator(), false).filter(matcher::match).toList();
final List<Root> result = rootRepository.findAll(builder.specification(RsqlParser.parse(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 RSQLToSQL(entityManager).toSQL(Root.class, null, rsql, G3),
e);
throw e;
}
return result;
}
@SpringBootConfiguration
static class Config {}
}

View File

@@ -0,0 +1,33 @@
/**
* Copyright (c) 2025 Contributors to the Eclipse Foundation
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.repository.jpa.rsql.sa;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
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;
}

View File

@@ -0,0 +1,18 @@
/**
* Copyright (c) 2025 Contributors to the Eclipse Foundation
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.repository.jpa.rsql.sa;
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> {}