Skip to content

Commit

Permalink
Support left join left joins when the search criterion involves a nes…
Browse files Browse the repository at this point in the history
…ted field. Support 'And' and 'Or' clauses together in method names. Refactors.
  • Loading branch information
aureamunoz committed Nov 20, 2024
1 parent c872c3b commit 11789fe
Show file tree
Hide file tree
Showing 24 changed files with 1,055 additions and 28 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.NoSuchElementException;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import jakarta.persistence.Id;

import org.jboss.jandex.AnnotationInstance;
import org.jboss.jandex.AnnotationValue;
Expand Down Expand Up @@ -81,6 +86,9 @@ public Result parse(MethodInfo methodInfo) {
ClassInfo repositoryClassInfo = methodInfo.declaringClass();
String repositoryMethodDescription = "'" + methodName + "' of repository '" + repositoryClassInfo + "'";
QueryType queryType = getType(methodName);
String entityAlias = getEntityName().toLowerCase();
String fromClause = "FROM " + getEntityName() + " AS " + entityAlias;
String joinClause = "";
if (queryType == null) {
throw new UnableToParseMethodException("Method " + repositoryMethodDescription
+ " cannot be parsed. Did you forget to annotate the method with '@Query'?");
Expand Down Expand Up @@ -167,22 +175,19 @@ public Result parse(MethodInfo methodInfo) {
List<String> parts = Collections.singletonList(afterByPart); // default when no 'And' or 'Or' exists
boolean containsAnd = containsLogicOperator(afterByPart, "And");
boolean containsOr = containsLogicOperator(afterByPart, "Or");
if (containsAnd && containsOr) {
throw new UnableToParseMethodException(
"'And' and 'Or' clauses cannot be mixed in a method name - Try specifying the Query with the @Query annotation. Offending method is "
+ repositoryMethodDescription + ".");
}
if (containsAnd) {
parts = Arrays.asList(afterByPart.split("And"));
} else if (containsOr) {
parts = Arrays.asList(afterByPart.split("Or"));
String[] partsArray = parts.toArray(new String[0]);
//Spring supports mixing clauses 'And' and 'Or' together in method names
if (containsAnd || containsOr) {
List<String> words = splitAndIncludeRegex(afterByPart, "And", "Or");
partsArray = words.toArray(new String[0]);
}

MutableReference<List<ClassInfo>> mappedSuperClassInfoRef = MutableReference.of(mappedSuperClassInfos);
StringBuilder where = new StringBuilder();
int paramsCount = 0;
for (String part : parts) {
if (part.isEmpty()) {
for (int i = 0; i < partsArray.length; i++) {
String part = partsArray[i];
if (part.isEmpty() || part.equals("And") || part.equals("Or")) {
continue;
}
String fieldName;
Expand All @@ -204,6 +209,26 @@ public Result parse(MethodInfo methodInfo) {
StringBuilder fieldPathBuilder = new StringBuilder(fieldName.length() + 5);
fieldInfo = resolveNestedField(repositoryMethodDescription, fieldName, fieldPathBuilder);
fieldName = fieldPathBuilder.toString();
String topLevelFieldName = getTopLevelFieldName(fieldName);
String childEntityAlias = topLevelFieldName;
if (fieldInfo != null) {
//logic to find the field mapping with the parent (with entityClass)
FieldInfo relatedParentFieldInfo = indexView
.getClassByName(fieldInfo.declaringClass().name().toString()).fields()
.stream()
.filter(fi -> fi.type().name().equals(DotName.createSimple(entityClass.name().toString())))
.findFirst().orElse(null);
if (relatedParentFieldInfo != null) {
joinClause = " LEFT JOIN " + topLevelFieldName + " " + childEntityAlias + " ON "
+ entityAlias + "." + getIdFieldInfo(entityClass).name() + " = "
+ topLevelFieldName + "." + relatedParentFieldInfo.name() + "."
+ getIdFieldInfo(entityClass).name();
} else {
// Fallback for cases where the relationship is not explicit
joinClause = " LEFT JOIN " + entityAlias + "." + topLevelFieldName + " " + childEntityAlias;
}

}
}
validateFieldWithOperation(operation, fieldInfo, fieldName, repositoryMethodDescription);
if ((ignoreCase || allIgnoreCase) && !DotNames.STRING.equals(fieldInfo.type().name())) {
Expand All @@ -213,7 +238,10 @@ public Result parse(MethodInfo methodInfo) {
}

if (where.length() > 0) {
where.append(containsAnd ? " AND " : " OR ");
if (containsAnd && partsArray[i - 1].equals("And"))
where.append(" AND ");
if (containsOr && partsArray[i - 1].equals("Or"))
where.append(" OR ");
}

String upperPrefix = (ignoreCase || allIgnoreCase) ? "UPPER(" : "";
Expand Down Expand Up @@ -328,10 +356,96 @@ public Result parse(MethodInfo methodInfo) {
}

String whereQuery = where.toString().isEmpty() ? "" : " WHERE " + where.toString();
return new Result(entityClass, "FROM " + getEntityName() + whereQuery, queryType, paramsCount, sort,
fromClause += joinClause;
return new Result(entityClass, fromClause + whereQuery, queryType, paramsCount, sort,
topCount);
}

/**
* Splits a given input string based on multiple regular expressions and includes each match in the result,
* maintaining the order of parts and separators as they appear in the original string.
*
* <p>
* This method allows you to provide multiple regex patterns that will be combined with an OR (`|`) operator,
* so any match from the list of patterns will act as a delimiter. The result will include both the segments
* of the input string that are between matches, as well as the matches themselves, in the order they appear.
*
* @param input the string to be split
* @param regexes one or more regular expression patterns to use as delimiters
* @return a list of strings representing the parts of the input string split by the specified regex patterns,
* with matches themselves included in the output list
*
* <p>
* Example usage:
*
* <pre>{@code
* String input = "StatusAndCustomerIdAndColor";
* List<String> result = splitAndIncludeRegex(input, "And");
* // result: [Status, And, CustomerId, And, Color]
* }</pre>
*/
private List<String> splitAndIncludeRegex(String input, String... regexes) {
List<String> result = new ArrayList<>();
StringBuilder patternBuilder = new StringBuilder();

// Create a pattern that combines all regex
for (String regex : regexes) {
if (patternBuilder.length() > 0) {
patternBuilder.append("|");
}
patternBuilder.append("(").append(regex).append(")");
}
Pattern pattern = Pattern.compile(patternBuilder.toString());
Matcher matcher = pattern.matcher(input);

int lastIndex = 0;
while (matcher.find()) {
// Add the part before the matching
if (matcher.start() > lastIndex) {
result.add(input.substring(lastIndex, matcher.start()));
}

// Add the regex
result.add(matcher.group());
lastIndex = matcher.end();
}

// Add the last part if exists
if (lastIndex < input.length()) {
result.add(input.substring(lastIndex));
}

return result;
}

/***
* This method extracts the first part of an input string (up to the first dot), corresponding to the class containing a
* field with the provided name.
* Or returns the entire input if no dot is found.
*
* @param input: whole path of a nested field
* <p>
* Example usage:
*
* <pre>{@code
* String input = "customer.name";
* List<String> result = getTopLevelFieldName(input);
* // result: customer
* }</pre>
*
*/
private String getTopLevelFieldName(String input) {
if (input == null) {
return null;
}
int dotIndex = input.indexOf('.');
// if dot not found the whole string is returned
if (dotIndex == -1) {
return input;
}
return input.substring(0, dotIndex);
}

private int indexOfOrMaxValue(String methodName, String term) {
int index = methodName.indexOf(term);
return index != -1 ? index : Integer.MAX_VALUE;
Expand Down Expand Up @@ -577,6 +691,18 @@ private FieldInfo getFieldInfo(String fieldName, ClassInfo entityClass,
return fieldInfo;
}

/**
* Retrieves the first field in the given entity class that is annotated with `@Id`.
*
* @param entityClass the `ClassInfo` object representing the entity class.
* @return the `FieldInfo` of the first field annotated with `@Id`.
* @throws NoSuchElementException if no field with the `@Id` annotation is found.
*/
private FieldInfo getIdFieldInfo(ClassInfo entityClass) {
return entityClass.fields().stream()
.filter(fieldInfo -> fieldInfo.hasAnnotation(DotName.createSimple(Id.class.getName()))).findFirst().get();
}

private List<ClassInfo> getSuperClassInfos(IndexView indexView, ClassInfo entityClass) {
List<ClassInfo> mappedSuperClassInfoElements = new ArrayList<>(3);
Type superClassType = entityClass.superClassType();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package io.quarkus.spring.data.deployment;

import java.time.ZonedDateTime;

import jakarta.persistence.CascadeType;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;

@Entity
public class Customer {

@Id
@GeneratedValue
private Integer id;
private String name;
private Integer age;
private ZonedDateTime birthDate;
private Boolean active;

@ManyToOne(cascade = CascadeType.ALL)
@JoinColumn(name = "address_id", referencedColumnName = "id")
private Address address;

public Customer() {
}

public Customer(String name, Integer age, ZonedDateTime birthDate, Boolean active) {
this.name = name;
this.age = age;
this.birthDate = birthDate;
this.active = active;
}

public Integer getId() {
return id;
}

public void setId(Integer id) {
this.id = id;
}

public String getName() {
return name;
}

public void setName(String name) {
this.name = name;
}

public Integer getAge() {
return age;
}

public void setAge(Integer age) {
this.age = age;
}

public ZonedDateTime getBirthDate() {
return birthDate;
}

public void setBirthDate(ZonedDateTime birthDate) {
this.birthDate = birthDate;
}

public Boolean getActive() {
return active;
}

public void setActive(Boolean active) {
this.active = active;
}

@Entity
public static class Address {
@Id
@GeneratedValue
private Integer id;

private String zipCode;

@ManyToOne(cascade = CascadeType.ALL)
@JoinColumn(name = "country_id", referencedColumnName = "id")
private Country country;

public Address(String zipCode, Country country) {
this.zipCode = zipCode;
this.country = country;
}

public Address() {

}
}

@Entity
public static class Country {
@Id
@GeneratedValue
private Integer id;

private String name;

private String isoCode;

public Country(String name, String isoCode) {
this.name = name;
this.isoCode = isoCode;
}

public Country() {

}
}
}
Loading

0 comments on commit 11789fe

Please sign in to comment.