From e664a07f327abafc5cd7c1d561995ffc517a8bef Mon Sep 17 00:00:00 2001 From: vasilchev Date: Tue, 11 Aug 2026 19:24:22 +0300 Subject: [PATCH] RSQL map filters: index-driven joins, fix ghost joins Signed-off-by: vasilchev --- .../hawkbit/ql/jpa/SpecificationBuilder.java | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/hawkbit-ql-jpa/src/main/java/org/eclipse/hawkbit/ql/jpa/SpecificationBuilder.java b/hawkbit-ql-jpa/src/main/java/org/eclipse/hawkbit/ql/jpa/SpecificationBuilder.java index 43f822d08f..a102fc5beb 100644 --- a/hawkbit-ql-jpa/src/main/java/org/eclipse/hawkbit/ql/jpa/SpecificationBuilder.java +++ b/hawkbit-ql-jpa/src/main/java/org/eclipse/hawkbit/ql/jpa/SpecificationBuilder.java @@ -154,10 +154,11 @@ private Predicate predicate(final Comparison comparison) { INVALID_SYNTAX, String.format("Operator %s is not supported for map fields with value null", op)); }; + } else if (isNot(op)) { + return compare(comparison, toMapValuePath(pathResolver.getJoinOnInner(attribute, split[1]))); } else { - // map entry with key not null (exist) - correlated EXISTS semi-join per predicate; - // avoids the LEFT JOIN + DISTINCT row explosion on large attribute tables - return existsMapEntry(comparison, attribute, split[1]); + final MapJoin mapPath = (MapJoin) pathResolver.getPath(attribute); + return 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])) { @@ -215,14 +216,6 @@ private static Path toMapValuePath(final Path mapJoin) { return valuePath.getJavaType() == String.class ? (Path) valuePath : valuePath.get("value"); } - // correlated EXISTS semi-join for a map entry with a non-null value filter (key/value in the subquery where) - private Predicate existsMapEntry(final Comparison comparison, final Attribute attribute, final String mapKey) { - final Subquery subquery = query.subquery(Integer.class); - final MapJoin mapJoin = (MapJoin) subquery.correlate(root).join(attribute.getName(), JoinType.INNER); - return cb.exists(subquery.select(cb.literal(1)) - .where(cb.and(equal(mapJoin.key(), mapKey), compare(comparison, toMapValuePath(mapJoin))))); - } - private Predicate compare(final Comparison comparison, final Path fieldPath) { final List values = getValues(comparison, fieldPath.getJavaType()); final Object firstValue = values.get(0); @@ -469,6 +462,10 @@ private Path getPath(final Attribute attribute) { return getCollectionPathResolver(attribute.getName()).getJoinOn(value); } + private MapJoin getJoinOnInner(final Attribute attribute, final Object value) { + return getCollectionPathResolver(attribute.getName()).getJoinOnInner(value); + } + private Map getState() { return attributeToPathResolver.entrySet().stream() .collect(Collectors.toMap(Map.Entry::getKey, resolver -> resolver.getValue().getPos())); @@ -491,6 +488,7 @@ private class CollectionPathResolver { @Setter private int pos; private final Map> joinOnCache = new HashMap<>(); + private final Map> joinOnInnerCache = new HashMap<>(); private CollectionPathResolver(final String attributeName) { this.attributeName = attributeName; @@ -514,6 +512,14 @@ private Path getPath() { 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; + }); + } } } }