diff --git a/hawkbit-repository/hawkbit-repository-jpa-eclipselink/src/main/java/org/eclipse/hawkbit/repository/jpa/Utils.java b/hawkbit-repository/hawkbit-repository-jpa-eclipselink/src/main/java/org/eclipse/hawkbit/repository/jpa/EclipselinkUtils.java similarity index 65% rename from hawkbit-repository/hawkbit-repository-jpa-eclipselink/src/main/java/org/eclipse/hawkbit/repository/jpa/Utils.java rename to hawkbit-repository/hawkbit-repository-jpa-eclipselink/src/main/java/org/eclipse/hawkbit/repository/jpa/EclipselinkUtils.java index fa8581025..3e2e3e479 100644 --- a/hawkbit-repository/hawkbit-repository-jpa-eclipselink/src/main/java/org/eclipse/hawkbit/repository/jpa/Utils.java +++ b/hawkbit-repository/hawkbit-repository-jpa-eclipselink/src/main/java/org/eclipse/hawkbit/repository/jpa/EclipselinkUtils.java @@ -9,7 +9,7 @@ */ package org.eclipse.hawkbit.repository.jpa; -import jakarta.persistence.TypedQuery; +import jakarta.persistence.Query; import lombok.NoArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -18,12 +18,12 @@ import org.eclipse.persistence.jpa.JpaQuery; @NoArgsConstructor(access = lombok.AccessLevel.PRIVATE) @Slf4j -public class Utils { +public class EclipselinkUtils { - public static String toSql(final TypedQuery typedQuery) { - typedQuery.setParameter(PersistenceUnitProperties.MULTITENANT_PROPERTY_DEFAULT, "DEFAULT"); + public static String toSql(final Query query) { + query.setParameter(PersistenceUnitProperties.MULTITENANT_PROPERTY_DEFAULT, "DEFAULT"); // executes the query - otherwise the SQL string is not generated - typedQuery.getResultList(); - return typedQuery.unwrap(JpaQuery.class).getDatabaseQuery().getSQLString(); + query.getResultList(); + return query.unwrap(JpaQuery.class).getDatabaseQuery().getSQLString(); } } \ No newline at end of file diff --git a/hawkbit-repository/hawkbit-repository-jpa-eclipselink/src/main/java/org/eclipse/hawkbit/repository/jpa/JpaConfiguration.java b/hawkbit-repository/hawkbit-repository-jpa-eclipselink/src/main/java/org/eclipse/hawkbit/repository/jpa/JpaConfiguration.java index ec99b80d6..bb32ddc8a 100644 --- a/hawkbit-repository/hawkbit-repository-jpa-eclipselink/src/main/java/org/eclipse/hawkbit/repository/jpa/JpaConfiguration.java +++ b/hawkbit-repository/hawkbit-repository-jpa-eclipselink/src/main/java/org/eclipse/hawkbit/repository/jpa/JpaConfiguration.java @@ -14,14 +14,17 @@ import java.util.Map; import javax.sql.DataSource; +import lombok.Data; import org.eclipse.hawkbit.tenancy.TenantAware; import org.eclipse.persistence.config.PersistenceUnitProperties; import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.autoconfigure.orm.jpa.JpaBaseConfiguration; import org.springframework.boot.autoconfigure.orm.jpa.JpaProperties; import org.springframework.boot.autoconfigure.transaction.TransactionManagerCustomizers; +import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; import org.springframework.orm.jpa.vendor.AbstractJpaVendorAdapter; import org.springframework.orm.jpa.vendor.EclipseLinkJpaDialect; import org.springframework.orm.jpa.vendor.EclipseLinkJpaVendorAdapter; @@ -32,16 +35,29 @@ import org.springframework.transaction.jta.JtaTransactionManager; * General EclipseLink configuration for hawkBit's Repository. */ @Configuration +@Import(JpaConfiguration.Properties.class) public class JpaConfiguration extends JpaBaseConfiguration { + @Data + @ConfigurationProperties // predix is "/" intentionally + protected static class Properties { + + private final Map eclipselink = new HashMap<>(); + } + private final TenantAware.TenantResolver tenantResolver; + // only for testing purposes ddl generation may be enabled + private final Map eclipselinkProperties; + protected JpaConfiguration( final DataSource dataSource, final JpaProperties properties, final ObjectProvider jtaTransactionManagerProvider, - final TenantAware.TenantResolver tenantResolver) { + final TenantAware.TenantResolver tenantResolver, + final Properties eclipselinkProperties) { super(dataSource, properties, jtaTransactionManagerProvider); this.tenantResolver = tenantResolver; + this.eclipselinkProperties = eclipselinkProperties.getEclipselink(); } /** @@ -76,7 +92,7 @@ public class JpaConfiguration extends JpaBaseConfiguration { properties.put(PersistenceUnitProperties.WEAVING, "false"); // needed for reports properties.put(PersistenceUnitProperties.ALLOW_NATIVE_SQL_QUERIES, "true"); - // flyway + // by default - none, flyway properties.put(PersistenceUnitProperties.DDL_GENERATION, "none"); // Embed into hawkBit logging properties.put(PersistenceUnitProperties.LOGGING_LOGGER, "JavaLogger"); @@ -86,6 +102,9 @@ public class JpaConfiguration extends JpaBaseConfiguration { properties.put(PersistenceUnitProperties.BATCH_WRITING, "JDBC"); // Batch size properties.put(PersistenceUnitProperties.BATCH_WRITING_SIZE, "500"); + + // override with all explicitly configured properties + properties.putAll(eclipselinkProperties); return properties; } } \ No newline at end of file diff --git a/hawkbit-repository/hawkbit-repository-jpa-hibernate/src/main/java/org/eclipse/hawkbit/repository/jpa/JpaConfiguration.java b/hawkbit-repository/hawkbit-repository-jpa-hibernate/src/main/java/org/eclipse/hawkbit/repository/jpa/JpaConfiguration.java index 499c3d627..e1453b975 100644 --- a/hawkbit-repository/hawkbit-repository-jpa-hibernate/src/main/java/org/eclipse/hawkbit/repository/jpa/JpaConfiguration.java +++ b/hawkbit-repository/hawkbit-repository-jpa-hibernate/src/main/java/org/eclipse/hawkbit/repository/jpa/JpaConfiguration.java @@ -13,6 +13,7 @@ import java.util.Collections; import java.util.HashMap; import java.util.Map; +import lombok.Data; import org.eclipse.hawkbit.repository.jpa.model.EntityPropertyChangeListener; import org.eclipse.hawkbit.repository.jpa.utils.JpaExceptionTranslator; @@ -31,8 +32,10 @@ import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.orm.jpa.JpaBaseConfiguration; import org.springframework.boot.autoconfigure.orm.jpa.JpaProperties; +import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; import org.springframework.orm.jpa.vendor.AbstractJpaVendorAdapter; import org.springframework.orm.jpa.vendor.HibernateJpaDialect; import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter; @@ -44,18 +47,27 @@ import javax.sql.DataSource; * General Hibernate configuration for hawkBit's Repository. */ @Configuration +@Import(JpaConfiguration.Properties.class) public class JpaConfiguration extends JpaBaseConfiguration { + @Data + @ConfigurationProperties // predix is "/" intentionally + protected static class Properties { + + private final Map hibernate = new HashMap<>(); + } + private final TenantIdentifier tenantIdentifier; - private final boolean enableLazyLoadNoTrans; + private final Map hibernateProperties; + protected JpaConfiguration( final DataSource dataSource, final JpaProperties properties, final ObjectProvider jtaTransactionManagerProvider, final TenantAware.TenantResolver tenantResolver, - @Value("${hibernate.enable-lazy-load-no-trans:true}") final boolean enableLazyLoadNoTrans) { + final Properties hibernateProperties) { super(dataSource, properties, jtaTransactionManagerProvider); tenantIdentifier = new TenantIdentifier(tenantResolver); - this.enableLazyLoadNoTrans = enableLazyLoadNoTrans; + this.hibernateProperties = hibernateProperties.getHibernate(); } @Bean @@ -77,16 +89,16 @@ public class JpaConfiguration extends JpaBaseConfiguration { } @Override - protected Map getVendorProperties(DataSource dataSource) { - Map hibernateProperties = new HashMap<>(); - hibernateProperties.put("hibernate.physical_naming_strategy", org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl.class.getName()); - hibernateProperties.put(MultiTenancySettings.MULTI_TENANT_IDENTIFIER_RESOLVER, tenantIdentifier); - hibernateProperties.put("hibernate.multiTenancy", "DISCRIMINATOR"); + protected Map getVendorProperties(final DataSource dataSource) { + final Map properties = new HashMap<>(); + properties.put("hibernate.physical_naming_strategy", org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl.class.getName()); + properties.put(MultiTenancySettings.MULTI_TENANT_IDENTIFIER_RESOLVER, tenantIdentifier); + properties.put("hibernate.multiTenancy", "DISCRIMINATOR"); // LAZY_LOAD - Enable lazy loading of lazy fields when session is closed - N + 1 problem occur. // So it would be good if in future hawkBit run without that // Otherwise, if false, call for the lazy field will throw LazyInitializationException - hibernateProperties.put("hibernate.enable_lazy_load_no_trans", enableLazyLoadNoTrans); - hibernateProperties.put("hibernate.integrator_provider", (IntegratorProvider) () -> Collections.singletonList(new Integrator() { + properties.put("hibernate.enable_lazy_load_no_trans", "true"); + properties.put("hibernate.integrator_provider", (IntegratorProvider) () -> Collections.singletonList(new Integrator() { @Override public void integrate( @@ -102,7 +114,10 @@ public class JpaConfiguration extends JpaBaseConfiguration { // do nothing } })); - return hibernateProperties; + + // override with all explicitly configured properties + properties.putAll(hibernateProperties); + return properties; } static class CustomHibernateJpaDialect extends HibernateJpaDialect { diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/rsql/RSQLUtility.java b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/rsql/RSQLUtility.java index 863159d3c..6ed4d937d 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/rsql/RSQLUtility.java +++ b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/rsql/RSQLUtility.java @@ -10,21 +10,12 @@ package org.eclipse.hawkbit.repository.jpa.rsql; import static org.eclipse.hawkbit.repository.rsql.RsqlConfigHolder.RsqlToSpecBuilder.G3; -import static org.eclipse.hawkbit.repository.rsql.RsqlConfigHolder.RsqlToSpecBuilder.LEGACY_G1; - -import java.io.Serial; -import java.util.List; import jakarta.persistence.EntityManager; import jakarta.persistence.criteria.CriteriaBuilder; import jakarta.persistence.criteria.CriteriaQuery; -import jakarta.persistence.criteria.Predicate; -import jakarta.persistence.criteria.Root; -import cz.jirutka.rsql.parser.RSQLParser; import cz.jirutka.rsql.parser.RSQLParserException; -import cz.jirutka.rsql.parser.ast.Node; -import cz.jirutka.rsql.parser.ast.RSQLVisitor; import lombok.AccessLevel; import lombok.NoArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -32,15 +23,12 @@ import org.apache.commons.lang3.text.StrLookup; import org.eclipse.hawkbit.repository.RsqlQueryField; import org.eclipse.hawkbit.repository.exception.RSQLParameterSyntaxException; import org.eclipse.hawkbit.repository.exception.RSQLParameterUnsupportedFieldException; -import org.eclipse.hawkbit.repository.jpa.rsqllegacy.JpaQueryRsqlVisitor; -import org.eclipse.hawkbit.repository.jpa.rsqllegacy.JpaQueryRsqlVisitorG2; import org.eclipse.hawkbit.repository.jpa.rsqllegacy.SpecificationBuilderLegacy; import org.eclipse.hawkbit.repository.rsql.RsqlConfigHolder; import org.eclipse.hawkbit.repository.rsql.VirtualPropertyReplacer; import org.eclipse.hawkbit.repository.rsql.VirtualPropertyResolver; import org.springframework.data.jpa.domain.Specification; import org.springframework.orm.jpa.vendor.Database; -import org.springframework.util.CollectionUtils; /** * A utility class which is able to parse RSQL strings into an spring data @@ -99,7 +87,11 @@ public final class RSQLUtility { return new SpecificationBuilder( virtualPropertyReplacer, !RsqlConfigHolder.getInstance().isCaseInsensitiveDB() && RsqlConfigHolder.getInstance().isIgnoreCase(), - database).specification(RsqlParser.parse(rsql, rsqlQueryFieldType)); + database) + .specification(RsqlParser.parse( + RsqlConfigHolder.getInstance().isCaseInsensitiveDB() || RsqlConfigHolder.getInstance().isIgnoreCase() + ? rsql.toLowerCase() : rsql, + rsqlQueryFieldType)); } else { return new SpecificationBuilderLegacy(rsqlQueryFieldType, virtualPropertyReplacer, database).specification(rsql); } @@ -113,7 +105,7 @@ public final class RSQLUtility { * @throws RSQLParserException if RSQL syntax is invalid * @throws RSQLParameterUnsupportedFieldException if RSQL key is not allowed */ - @SuppressWarnings({"unchecked", "rawtypes"}) + @SuppressWarnings({ "unchecked", "rawtypes" }) public static & RsqlQueryField> void validateRsqlFor( final String rsql, final Class rsqlQueryFieldType, final Class jpaType, @@ -121,6 +113,6 @@ public final class RSQLUtility { final CriteriaBuilder criteriaBuilder = entityManager.getCriteriaBuilder(); final CriteriaQuery criteriaQuery = criteriaBuilder.createQuery(jpaType); buildRsqlSpecification(rsql, rsqlQueryFieldType, virtualPropertyReplacer, null) - .toPredicate(criteriaQuery.from((Class)jpaType), criteriaQuery, criteriaBuilder); + .toPredicate(criteriaQuery.from((Class) jpaType), criteriaQuery, criteriaBuilder); } } \ No newline at end of file diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/rsql/RsqlParser.java b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/rsql/RsqlParser.java index 43768dbbb..25f6bbbb2 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/rsql/RsqlParser.java +++ b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/rsql/RsqlParser.java @@ -45,7 +45,6 @@ import org.eclipse.hawkbit.repository.RsqlQueryField; import org.eclipse.hawkbit.repository.exception.RSQLParameterSyntaxException; import org.eclipse.hawkbit.repository.exception.RSQLParameterUnsupportedFieldException; import org.eclipse.hawkbit.repository.jpa.rsql.Node.Comparison; -import org.eclipse.hawkbit.repository.rsql.RsqlConfigHolder; /** * {@link RsqlParser} parses RSQL query stings to {@link Node} objects. Doing that it does the following: @@ -82,15 +81,13 @@ public class RsqlParser { } public static & RsqlQueryField> Node parse(final String rsql, final Class rsqlQueryFieldType) { - return parse(rsql, key -> resolveKey(key, rsqlQueryFieldType)); + return parse(rsql, rsqlQueryFieldType == null ? null : key -> resolveKey(key, rsqlQueryFieldType)); } private static Node parse(final String rsql, final Function keyResolver) { try { return PARSER - .parse(RsqlConfigHolder.getInstance().isCaseInsensitiveDB() || RsqlConfigHolder.getInstance().isIgnoreCase() - ? rsql.toLowerCase() - : rsql) + .parse(rsql) .accept(new RsqlVisitor(keyResolver)); } catch (final RSQLParserException e) { throw new RSQLParameterSyntaxException(e); diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/rsql/SpecificationBuilder.java b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/rsql/SpecificationBuilder.java index 7674fd9cf..a0c9e4ad0 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/rsql/SpecificationBuilder.java +++ b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/rsql/SpecificationBuilder.java @@ -152,11 +152,9 @@ public class SpecificationBuilder { } else { final MapJoin mapPath = (MapJoin) pathResolver.getPath(attribute); final Path valuePath = (Path) mapPath.value(); - return cb.and( - equal(mapPath.key(), split[1]), - isNot(op) - ? notEqualInLike(comparison, (PluralAttribute) attribute, null) - : compare(comparison, valuePath)); + return isNot(op) + ? compare(comparison, pathResolver.getJoinOnInner(attribute, split[1])) + : cb.and(equal(mapPath.key(), split[1]), compare(comparison, valuePath)); } } else if (attribute instanceof SetAttribute setAttribute) { if (split.length < 2 || ObjectUtils.isEmpty(split[1])) { @@ -413,6 +411,10 @@ public class SpecificationBuilder { return getCollectionPathResolver(attribute.getName()).getJoinOn(value); } + private MapJoin getJoinOnInner(final Attribute attribute, final Object value) { + return getCollectionPathResolver(attribute.getName()).getJoinOnInner(value); + } + private void reset() { attributeToPathResolver.values().forEach(CollectionPathResolver::reset); } @@ -428,6 +430,7 @@ public class SpecificationBuilder { private final List> paths = new ArrayList<>(); private int pos; private final Map> joinOnCache = new HashMap<>(); + private final Map> joinOnInnerCache = new HashMap<>(); private CollectionPathResolver(final String attributeName) { this.attributeName = attributeName; @@ -452,6 +455,14 @@ public class SpecificationBuilder { }); } + 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; + }); + } + private void reset() { pos = 0; } diff --git a/hawkbit-repository/hawkbit-repository-jpa-hibernate/src/main/java/org/eclipse/hawkbit/repository/jpa/Utils.java b/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/rsql/HibernateUtils.java similarity index 95% rename from hawkbit-repository/hawkbit-repository-jpa-hibernate/src/main/java/org/eclipse/hawkbit/repository/jpa/Utils.java rename to hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/rsql/HibernateUtils.java index 80cad6656..810989d76 100644 --- a/hawkbit-repository/hawkbit-repository-jpa-hibernate/src/main/java/org/eclipse/hawkbit/repository/jpa/Utils.java +++ b/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/rsql/HibernateUtils.java @@ -7,14 +7,14 @@ * * SPDX-License-Identifier: EPL-2.0 */ -package org.eclipse.hawkbit.repository.jpa; +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.TypedQuery; +import jakarta.persistence.Query; import lombok.NoArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -41,7 +41,7 @@ import org.hibernate.sql.exec.spi.JdbcParametersList; @NoArgsConstructor(access = lombok.AccessLevel.PRIVATE) @Slf4j -public class Utils { +public class HibernateUtils { private static final Method getSqmTranslatorFactory; @@ -55,12 +55,12 @@ public class Utils { getSqmTranslatorFactory = method; } - public static String toSql(final TypedQuery typedQuery) { + public static String toSql(final Query query) { if (getSqmTranslatorFactory == null) { throw new UnsupportedOperationException("SqmTranslatorFactory resolver is not available"); } - final QuerySqmImpl hqlQuery = typedQuery.unwrap(QuerySqmImpl.class); + final QuerySqmImpl hqlQuery = query.unwrap(QuerySqmImpl.class); final SessionFactoryImplementor factory = hqlQuery.getSessionFactory(); final SharedSessionContractImplementor session = hqlQuery.getSession(); final SessionFactoryImplementor sessionFactory = session.getFactory(); diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/rsql/RSQLToSQL.java b/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/rsql/RSQLToSQL.java index c576d4933..c077e6c78 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/rsql/RSQLToSQL.java +++ b/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/rsql/RSQLToSQL.java @@ -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 & RsqlQueryField> String toSQL( final Class domainClass, final Class fieldsClass, final String rsql, final RsqlToSpecBuilder rsqlToSpecBuilder) { final CriteriaQuery 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 & RsqlQueryField> CriteriaQuery createQuery( diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/rsql/RSQLToSQLTest.java b/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/rsql/RSQLToSQLTest.java index 8cf85ea3e..50579c524 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/rsql/RSQLToSQLTest.java +++ b/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/rsql/RSQLToSQLTest.java @@ -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) { diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/rsql/sa/ReferenceMatcher.java b/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/rsql/sa/ReferenceMatcher.java new file mode 100644 index 000000000..033336864 --- /dev/null +++ b/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/rsql/sa/ReferenceMatcher.java @@ -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)); + } + + boolean match(final T t) { + return match(t, root); + } + + private static 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 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 Method getGetter(final Class 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) 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())); + } + } +} \ No newline at end of file diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/rsql/sa/Root.java b/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/rsql/sa/Root.java new file mode 100644 index 000000000..c03369dca --- /dev/null +++ b/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/rsql/sa/Root.java @@ -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 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 subMap; +} diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/rsql/sa/RootRepository.java b/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/rsql/sa/RootRepository.java new file mode 100644 index 000000000..0763b1d6b --- /dev/null +++ b/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/rsql/sa/RootRepository.java @@ -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, JpaSpecificationExecutor {} diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/rsql/sa/SpecificationBuilderTest.java b/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/rsql/sa/SpecificationBuilderTest.java new file mode 100644 index 000000000..9a7fc9634 --- /dev/null +++ b/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/rsql/sa/SpecificationBuilderTest.java @@ -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 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("strValuerootX")).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.strValuesubX")).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.strValuesubX")).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.xrootX")).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 filter(final String rsql) { + // reference / auto filter (using elements and reflection) + final ReferenceMatcher matcher = ReferenceMatcher.ofRsql(rsql); + final List refResult = StreamSupport.stream(rootRepository.findAll().spliterator(), false).filter(matcher::match).toList(); + final List 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 {} +} \ No newline at end of file diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/rsql/sa/Sub.java b/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/rsql/sa/Sub.java new file mode 100644 index 000000000..9a9f2598e --- /dev/null +++ b/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/rsql/sa/Sub.java @@ -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; +} diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/rsql/sa/SubRepository.java b/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/rsql/sa/SubRepository.java new file mode 100644 index 000000000..6c72a3531 --- /dev/null +++ b/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/rsql/sa/SubRepository.java @@ -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, JpaSpecificationExecutor {}