PropertyPath.java
package expresspecs;
import static org.apache.commons.lang3.ArrayUtils.isEmpty;
import static org.apache.commons.lang3.StringUtils.isBlank;
import java.util.Arrays;
import java.util.List;
import org.apache.commons.lang3.StringUtils;
import jakarta.persistence.criteria.From;
import jakarta.persistence.criteria.JoinType;
import jakarta.persistence.criteria.Path;
import jakarta.persistence.criteria.Root;
import jakarta.persistence.metamodel.Attribute;
import jakarta.persistence.metamodel.ManagedType;
import jakarta.persistence.metamodel.PluralAttribute;
import jakarta.persistence.metamodel.SingularAttribute;
/**
* Represents a path through a JPA entity graph for use in Criteria queries.
*
* <p>A {@code PropertyPath} models a chain of attribute names traversing from a root entity
* through zero or more intermediate associations or embeddables to a leaf attribute. It can be
* constructed from dot-separated strings (e.g., {@code "address.city"}), string varargs, or
* JPA metamodel attributes (e.g., {@code Customer_.address}, {@code Address_.city}), and
* resolves itself into a JPA {@link Path} via {@link #asPath(Root)}.
*
* <p>During resolution, intermediate segments that are entity associations ({@code @ManyToOne},
* {@code @OneToOne}, etc.) are traversed using LEFT JOINs, while embeddable or simple
* intermediate segments use standard path navigation. The final (leaf) segment is always
* resolved with {@code get()}, it is assumed to be the property being used in a predicate,
* not a relationship being navigated through. This means the leaf should be a simple property,
* an embeddable, or an association compared by its foreign key (e.g., {@code cb.equal()} or
* {@code cb.isNull()}).
*/
public record PropertyPath(List<String> properties) {
/** Exists solely to resolve the ambiguity between the two varargs overloads when called with no arguments. */
public static PropertyPath of() {
throw new IllegalArgumentException("PropertyPath must contain at least one property");
}
/**
* Factory method to create a path from one or more segments.
*/
public static PropertyPath of(String... properties) {
if (isEmpty(properties)) {
throw new IllegalArgumentException("PropertyPath must contain at least one property");
}
return new PropertyPath(List.of(properties));
}
/**
* Factory method to construct a path from a dot-separated string.
* Example: PropertyPath.fromDotSeparated("address.city")
*/
public static PropertyPath from(String path) {
if (isBlank(path)) {
throw new IllegalArgumentException("Path string must not be blank");
}
final String[] properties = path.split("\\.");
return new PropertyPath(List.of(properties));
}
/**
* Factory method to create a path from one or more JPA metamodel attributes.
*
* <p>Accepts any mix of attribute types, including plural (collection) attributes.
* The attribute names are extracted in order to form the path. This overload provides
* refactoring safety: a renamed entity field breaks compilation here, but does not
* enforce that the attribute types form a valid chain. For chain-type validation,
* use the typed two- or three-segment overloads.
*
* <p>Example: {@code PropertyPath.of(Customer_.address, Address_.zipCode)}
*/
public static PropertyPath of(Attribute<?, ?>... attributes) {
if (isEmpty(attributes)) {
throw new IllegalArgumentException("PropertyPath must contain at least one attribute");
}
return new PropertyPath(Arrays.stream(attributes).map(Attribute::getName).toList());
}
/**
* Type-safe two-segment path through a singular (to-one) association or embeddable.
*
* <p>The compiler verifies that {@code second} belongs to the type produced by {@code first}.
*
* <p>Example: {@code PropertyPath.of(Customer_.address, Address_.zipCode)}
*/
public static <X, Y, Z> PropertyPath of(SingularAttribute<X, Y> first, Attribute<Y, Z> second) {
return new PropertyPath(List.of(first.getName(), second.getName()));
}
/**
* Type-safe two-segment path through a plural (to-many) association.
*
* <p>The compiler verifies that {@code second} belongs to the element type of the collection
* produced by {@code first}, not the collection type itself.
*
* <p>Example: {@code PropertyPath.of(Customer_.orders, Order_.datePlaced)}
*/
public static <X, E, Z> PropertyPath of(PluralAttribute<X, ?, E> first, Attribute<E, Z> second) {
return new PropertyPath(List.of(first.getName(), second.getName()));
}
/**
* Type-safe three-segment path through two singular associations or embeddables.
*
* <p>Example: {@code PropertyPath.of(Customer_.address, Address_.region, Region_.name)}
*/
public static <X, Y, Z, W> PropertyPath of(SingularAttribute<X, Y> first, SingularAttribute<Y, Z> second, Attribute<Z, W> third) {
return new PropertyPath(List.of(first.getName(), second.getName(), third.getName()));
}
/**
* Type-safe three-segment path through a singular association followed by a plural one.
*
* <p>Example: {@code PropertyPath.of(Invoice_.customer, Customer_.orders, Order_.datePlaced)}
*/
public static <X, Y, E, W> PropertyPath of(SingularAttribute<X, Y> first, PluralAttribute<Y, ?, E> second, Attribute<E, W> third) {
return new PropertyPath(List.of(first.getName(), second.getName(), third.getName()));
}
@Override
public String toString() {
return StringUtils.join(properties, '.');
}
@SuppressWarnings("unchecked")
public <V> Path<V> asPath(Root<?> root) {
List<String> intermediates = properties.subList(0, properties.size() - 1);
String leaf = properties.get(properties.size() - 1);
// Navigate through intermediate segments (associations join, embeddables get)
Path<?> current = root;
for (String segment : intermediates) {
if (isAssociation(current, segment)) {
// When encountering an association (relation to another entity), we will
// reuse an existing JOIN if one exists
current = findOrCreateJoin((From<?, ?>) current, segment);
} else {
current = current.get(segment);
}
}
// Resolve the leaf segment
return (Path<V>) current.get(leaf);
}
private boolean isAssociation(Path<?> path, String segment) {
var model = path.getModel();
if (model instanceof ManagedType<?> managedType) {
return managedType.getAttribute(segment).isAssociation();
}
return false;
}
/**
* Resolves an association segment by either reusing an existing join or creating a new one.
*
* <p>This exists to prevent redundant JOINs in the generated SQL when multiple Specification
* fragments navigate the same association path. Reusing joins ensures that criteria are
* applied to the same related record rather than potentially different records of the
* same type.</p>
*
* <p>It works by inspecting the existing joins on the {@code from} path. If a join for the
* specified {@code segment} already exists, it is returned. Otherwise, a new LEFT JOIN is
* initialized and added to the query.</p>
*
* @param from The source path from which to join.
* @param segment The name of the association attribute.
*/
private From<?, ?> findOrCreateJoin(From<?, ?> from, String segment) {
return from.getJoins().stream()
.filter(j -> j.getAttribute().getName().equals(segment))
.findFirst()
.map(j -> (From<?, ?>) j)
.orElseGet(() -> from.join(segment, JoinType.LEFT));
}
}