Skip to content

Commit 84f947c

Browse files
committed
DATAJPA-1822 - use left outer joins for nested optionals
1 parent 5b777c1 commit 84f947c

File tree

6 files changed

+263
-74
lines changed

6 files changed

+263
-74
lines changed

Diff for: src/main/java/org/springframework/data/jpa/repository/query/QueryUtils.java

+92-66
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
import java.util.List;
3030
import java.util.Locale;
3131
import java.util.Map;
32+
import java.util.Objects;
3233
import java.util.Set;
3334
import java.util.regex.Matcher;
3435
import java.util.regex.Pattern;
@@ -41,11 +42,9 @@
4142
import javax.persistence.Query;
4243
import javax.persistence.criteria.CriteriaBuilder;
4344
import javax.persistence.criteria.Expression;
44-
import javax.persistence.criteria.Fetch;
4545
import javax.persistence.criteria.From;
4646
import javax.persistence.criteria.Join;
4747
import javax.persistence.criteria.JoinType;
48-
import javax.persistence.criteria.Path;
4948
import javax.persistence.metamodel.Attribute;
5049
import javax.persistence.metamodel.Attribute.PersistentAttributeType;
5150
import javax.persistence.metamodel.Bindable;
@@ -620,12 +619,74 @@ static <T> Expression<T> toExpressionRecursively(From<?, ?> from, PropertyPath p
620619
return toExpressionRecursively(from, property, false);
621620
}
622621

623-
@SuppressWarnings("unchecked")
624622
static <T> Expression<T> toExpressionRecursively(From<?, ?> from, PropertyPath property, boolean isForSelection) {
623+
return toExpressionRecursively(from, property, isForSelection, false);
624+
}
625+
626+
/**
627+
* Creates an expression with proper inner and left joins by recursively navigating the path
628+
*
629+
* @param from the {@link From}
630+
* @param property the property path
631+
* @param isForSelection is the property navigated for the selection or ordering part of the query?
632+
* @param hasRequiredOuterJoin has a parent already required an outer join?
633+
* @param <T> the type of the expression
634+
* @return the expression
635+
*/
636+
@SuppressWarnings("unchecked") static <T> Expression<T> toExpressionRecursively(From<?, ?> from,
637+
PropertyPath property, boolean isForSelection, boolean hasRequiredOuterJoin) {
638+
639+
String segment = property.getSegment();
640+
641+
boolean isLeafProperty = !property.hasNext();
642+
643+
boolean requiresOuterJoin = requiresOuterJoin(from, property, isForSelection, hasRequiredOuterJoin);
644+
645+
// if it does not require an outer join and is a leaf, simply get the segment
646+
if (!requiresOuterJoin && isLeafProperty) {
647+
return from.get(segment);
648+
}
649+
650+
// get or create the join
651+
JoinType joinType = requiresOuterJoin ? JoinType.LEFT : JoinType.INNER;
652+
Join<?, ?> join = getOrCreateJoin(from, segment, joinType);
653+
654+
// if it's a leaf, return the join
655+
if (isLeafProperty) {
656+
return (Expression<T>) join;
657+
}
658+
659+
PropertyPath nextProperty = Objects.requireNonNull(property.next(), "An element of the property path is null!");
660+
661+
// recurse with the next property
662+
return toExpressionRecursively(join, nextProperty, isForSelection, requiresOuterJoin);
663+
}
664+
665+
/**
666+
* Checks if this attribute requires an outer join.
667+
* This is the case eg. if it hadn't already been fetched with an inner join and if it's an a optional association,
668+
* and if previous paths has already required outer joins.
669+
* It also ensures outer joins are used even when Hibernate defaults to inner joins (HHH-12712 and HHH-12999).
670+
*
671+
* @param from the {@link From} to check for fetches.
672+
* @param property the property path
673+
* @param isForSelection is the property navigated for the selection or ordering part of the query? if true,
674+
* we need to generate an explicit outer join in order to prevent Hibernate to use an
675+
* inner join instead. see https://hibernate.atlassian.net/browse/HHH-12999
676+
* @param hasRequiredOuterJoin has a parent already required an outer join?
677+
* @return whether an outer join is to be used for integrating this attribute in a query.
678+
*/
679+
private static boolean requiresOuterJoin(From<?, ?> from, PropertyPath property, boolean isForSelection,
680+
boolean hasRequiredOuterJoin) {
681+
682+
String segment = property.getSegment();
683+
684+
// already inner joined so outer join is useless
685+
if (isAlreadyInnerJoined(from, segment))
686+
return false;
625687

626688
Bindable<?> propertyPathModel;
627689
Bindable<?> model = from.getModel();
628-
String segment = property.getSegment();
629690

630691
if (model instanceof ManagedType) {
631692

@@ -638,29 +699,10 @@ static <T> Expression<T> toExpressionRecursively(From<?, ?> from, PropertyPath p
638699
propertyPathModel = from.get(segment).getModel();
639700
}
640701

641-
if (requiresOuterJoin(propertyPathModel, model instanceof PluralAttribute, !property.hasNext(), isForSelection)
642-
&& !isAlreadyFetched(from, segment)) {
643-
Join<?, ?> join = getOrCreateJoin(from, segment);
644-
return (Expression<T>) (property.hasNext() ? toExpressionRecursively(join, property.next(), isForSelection)
645-
: join);
646-
} else {
647-
Path<Object> path = from.get(segment);
648-
return (Expression<T>) (property.hasNext() ? toExpressionRecursively(path, property.next()) : path);
649-
}
650-
}
702+
// is the attribute of Collection type?
703+
boolean isPluralAttribute = model instanceof PluralAttribute;
651704

652-
/**
653-
* Returns whether the given {@code propertyPathModel} requires the creation of a join. This is the case if we find a
654-
* optional association.
655-
*
656-
* @param propertyPathModel may be {@literal null}.
657-
* @param isPluralAttribute is the attribute of Collection type?
658-
* @param isLeafProperty is this the final property navigated by a {@link PropertyPath}?
659-
* @param isForSelection is the property navigated for the selection part of the query?
660-
* @return whether an outer join is to be used for integrating this attribute in a query.
661-
*/
662-
private static boolean requiresOuterJoin(@Nullable Bindable<?> propertyPathModel, boolean isPluralAttribute,
663-
boolean isLeafProperty, boolean isForSelection) {
705+
boolean isLeafProperty = !property.hasNext();
664706

665707
if (propertyPathModel == null && isPluralAttribute) {
666708
return true;
@@ -672,24 +714,23 @@ private static boolean requiresOuterJoin(@Nullable Bindable<?> propertyPathModel
672714

673715
Attribute<?, ?> attribute = (Attribute<?, ?>) propertyPathModel;
674716

717+
// not a persistent attribute type association (@OneToOne, @ManyToOne)
675718
if (!ASSOCIATION_TYPES.containsKey(attribute.getPersistentAttributeType())) {
676719
return false;
677720
}
678721

679-
// if this path is an optional one to one attribute navigated from the not owning side we also need an explicit
680-
// outer join to avoid https://hibernate.atlassian.net/browse/HHH-12712 and
681-
// https://github.com/eclipse-ee4j/jpa-api/issues/170
722+
boolean isCollection = attribute.isCollection();
723+
// if this path is an optional one to one attribute navigated from the not owning side we also need an
724+
// explicit outer join to avoid https://hibernate.atlassian.net/browse/HHH-12712
725+
// and https://github.com/eclipse-ee4j/jpa-api/issues/170
682726
boolean isInverseOptionalOneToOne = PersistentAttributeType.ONE_TO_ONE == attribute.getPersistentAttributeType()
683727
&& StringUtils.hasText(getAnnotationProperty(attribute, "mappedBy", ""));
684728

685-
// if this path is part of the select list we need to generate an explicit outer join in order to prevent Hibernate
686-
// to use an inner join instead.
687-
// see https://hibernate.atlassian.net/browse/HHH-12999.
688-
if (isLeafProperty && !isForSelection && !attribute.isCollection() && !isInverseOptionalOneToOne) {
729+
if (isLeafProperty && !isForSelection && !isCollection && !isInverseOptionalOneToOne && !hasRequiredOuterJoin) {
689730
return false;
690731
}
691732

692-
return getAnnotationProperty(attribute, "optional", true);
733+
return hasRequiredOuterJoin || getAnnotationProperty(attribute, "optional", true);
693734
}
694735

695736
private static <T> T getAnnotationProperty(Attribute<?, ?> attribute, String propertyName, T defaultValue) {
@@ -710,52 +751,37 @@ private static <T> T getAnnotationProperty(Attribute<?, ?> attribute, String pro
710751
return annotation == null ? defaultValue : (T) AnnotationUtils.getValue(annotation, propertyName);
711752
}
712753

713-
static Expression<Object> toExpressionRecursively(Path<Object> path, PropertyPath property) {
714-
715-
Path<Object> result = path.get(property.getSegment());
716-
return property.hasNext() ? toExpressionRecursively(result, property.next()) : result;
717-
}
718-
719754
/**
720-
* Returns an existing join for the given attribute if one already exists or creates a new one if not.
755+
* Returns an existing join for the given attribute and join type if one already exists or creates a new one if not.
721756
*
722-
* @param from the {@link From} to get the current joins from.
757+
* @param from the {@link From} to get the current joins from.
723758
* @param attribute the {@link Attribute} to look for in the current joins.
759+
* @param joinType the join type
724760
* @return will never be {@literal null}.
725761
*/
726-
private static Join<?, ?> getOrCreateJoin(From<?, ?> from, String attribute) {
727-
728-
for (Join<?, ?> join : from.getJoins()) {
729-
730-
boolean sameName = join.getAttribute().getName().equals(attribute);
731-
732-
if (sameName && join.getJoinType().equals(JoinType.LEFT)) {
733-
return join;
734-
}
735-
}
736-
737-
return from.join(attribute, JoinType.LEFT);
762+
private static Join<?, ?> getOrCreateJoin(From<?, ?> from, String attribute, JoinType joinType) {
763+
return from.getJoins().stream()
764+
.filter(join -> join.getAttribute().getName().equals(attribute) && join.getJoinType().equals(joinType))
765+
.findFirst()
766+
.orElseGet(() -> from.join(attribute, joinType));
738767
}
739768

740769
/**
741-
* Return whether the given {@link From} contains a fetch declaration for the attribute with the given name.
770+
* Return whether the given {@link From} contains an inner join for the attribute with the given name.
742771
*
743-
* @param from the {@link From} to check for fetches.
772+
* @param from the {@link From} to check for joins.
744773
* @param attribute the attribute name to check.
745-
* @return
774+
* @return true if the attribute has already been inner joined
746775
*/
747-
private static boolean isAlreadyFetched(From<?, ?> from, String attribute) {
748-
749-
for (Fetch<?, ?> fetch : from.getFetches()) {
776+
private static boolean isAlreadyInnerJoined(From<?, ?> from, String attribute) {
750777

751-
boolean sameName = fetch.getAttribute().getName().equals(attribute);
778+
boolean isInnerJoinFetched = from.getFetches().stream().anyMatch(
779+
fetch -> fetch.getAttribute().getName().equals(attribute) && fetch.getJoinType().equals(JoinType.INNER));
752780

753-
if (sameName && fetch.getJoinType().equals(JoinType.LEFT)) {
754-
return true;
755-
}
756-
}
781+
boolean isSimplyInnerJoined = from.getJoins().stream()
782+
.anyMatch(join -> join.getAttribute().getName().equals(attribute) && join.getJoinType().equals(JoinType.INNER));
757783

758-
return false;
784+
return isInnerJoinFetched || isSimplyInnerJoined;
759785
}
760786

761787
/**

Diff for: src/test/java/org/springframework/data/jpa/domain/sample/Customer.java

+5-1
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,13 @@
2020

2121
/**
2222
* @author Oliver Gierke
23+
* @author Patrice Blanchardie
2324
*/
2425
@Entity
2526
public class Customer {
2627

27-
@Id Long id;
28+
@Id
29+
Long id;
30+
31+
String name;
2832
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/*
2+
* Copyright 2020 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.springframework.data.jpa.domain.sample;
17+
18+
import javax.persistence.Entity;
19+
import javax.persistence.Id;
20+
import javax.persistence.ManyToOne;
21+
import javax.persistence.Table;
22+
23+
/**
24+
* @author Patrice Blanchardie
25+
*/
26+
@Entity
27+
@Table(name = "INVOICES")
28+
public class Invoice {
29+
30+
@Id Long id;
31+
32+
@ManyToOne(optional = false) Customer customer;
33+
34+
@ManyToOne Order order;
35+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/*
2+
* Copyright 2020 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.springframework.data.jpa.domain.sample;
17+
18+
import javax.persistence.Entity;
19+
import javax.persistence.Id;
20+
import javax.persistence.ManyToOne;
21+
import javax.persistence.Table;
22+
23+
/**
24+
* @author Patrice Blanchardie
25+
*/
26+
@Entity
27+
@Table(name = "INVOICE_ITEMS")
28+
public class InvoiceItem {
29+
30+
@Id Long id;
31+
32+
@ManyToOne(optional = false) Invoice invoice;
33+
}

0 commit comments

Comments
 (0)