diff --git a/extensions/spring-data-jpa/deployment/src/main/java/io/quarkus/spring/data/deployment/MethodNameParser.java b/extensions/spring-data-jpa/deployment/src/main/java/io/quarkus/spring/data/deployment/MethodNameParser.java index e7212fb386475..6c713a27fe8d5 100644 --- a/extensions/spring-data-jpa/deployment/src/main/java/io/quarkus/spring/data/deployment/MethodNameParser.java +++ b/extensions/spring-data-jpa/deployment/src/main/java/io/quarkus/spring/data/deployment/MethodNameParser.java @@ -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; @@ -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'?"); @@ -167,22 +175,19 @@ public Result parse(MethodInfo methodInfo) { List 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 words = splitAndIncludeRegex(afterByPart, "And", "Or"); + partsArray = words.toArray(new String[0]); } MutableReference> 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; @@ -204,6 +209,22 @@ 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) { + //Find the field of the child entity mapping 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(); + } + + } } validateFieldWithOperation(operation, fieldInfo, fieldName, repositoryMethodDescription); if ((ignoreCase || allIgnoreCase) && !DotNames.STRING.equals(fieldInfo.type().name())) { @@ -213,7 +234,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(" : ""; @@ -328,10 +352,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. + * + *

+ * 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 + * + *

+ * Example usage: + * + *

{@code
+     * String input = "StatusAndCustomerIdAndColor";
+     * List result = splitAndIncludeRegex(input, "And");
+     * // result: [Status, And, CustomerId, And, Color]
+     * }
+ */ + private List splitAndIncludeRegex(String input, String... regexes) { + List 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 + *

+ * Example usage: + * + *

{@code
+     * String input = "customer.name";
+     * List result = getTopLevelFieldName(input);
+     * // result: customer
+     * }
+ * + */ + 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; @@ -577,6 +687,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 getSuperClassInfos(IndexView indexView, ClassInfo entityClass) { List mappedSuperClassInfoElements = new ArrayList<>(3); Type superClassType = entityClass.superClassType(); diff --git a/extensions/spring-data-jpa/deployment/src/test/java/io/quarkus/spring/data/deployment/Customer.java b/extensions/spring-data-jpa/deployment/src/test/java/io/quarkus/spring/data/deployment/Customer.java new file mode 100644 index 0000000000000..d2674a34559f9 --- /dev/null +++ b/extensions/spring-data-jpa/deployment/src/test/java/io/quarkus/spring/data/deployment/Customer.java @@ -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() { + + } + } +} diff --git a/extensions/spring-data-jpa/deployment/src/test/java/io/quarkus/spring/data/deployment/CustomerRepository.java b/extensions/spring-data-jpa/deployment/src/test/java/io/quarkus/spring/data/deployment/CustomerRepository.java new file mode 100644 index 0000000000000..cbc2381415050 --- /dev/null +++ b/extensions/spring-data-jpa/deployment/src/test/java/io/quarkus/spring/data/deployment/CustomerRepository.java @@ -0,0 +1,86 @@ +package io.quarkus.spring.data.deployment; + +import java.time.ZonedDateTime; +import java.util.Collection; +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CustomerRepository extends JpaRepository { + + List findByName(String name); + + List findByNameIs(String name); + + List findByNameEquals(String name); + + List findByNameIsNull(); + + List findByNameNot(String name); + + List findByNameIsNot(String name); + + List findByNameStartingWith(String name); + + List findByNameEndingWith(String name); + + List findByNameContaining(String name); + + List findByNameLike(String name); + + List findByAgeLessThan(Integer age); + + List findByAgeLessThanEqual(Integer age); + + List findByAgeGreaterThan(Integer age); + + List findByAgeGreaterThanEqual(Integer age); + + List findByAgeBetween(Integer startAge, Integer endAge); + + List findByBirthDateAfter(ZonedDateTime birthDate); + + List findByBirthDateBefore(ZonedDateTime birthDate); + + List findByActiveTrue(); + + List findByActiveFalse(); + + List findByAgeIn(Collection ages); + + List findByNameOrAge(String name, Integer age); + + List findAllByNameAndAgeAndActive(String name, int age, boolean active); + + List findAllByNameAndAgeOrActive(String name, int age, boolean active); + + List findAllByNameOrAgeOrActive(String name, int age, boolean active); + + List findByNameOrAgeAndActive(String name, Integer age, Boolean active); + + List findByNameOrderByName(String name); + + List findByNameOrderByNameDesc(String name); + + List findByNameIsNotNull(); + + List findByNameOrderByNameAsc(String name); + + //-------------------nested fields-------------------------------- + List findAllByAddressZipCode(String zipCode); + + List findAllByAddressCountryIsoCode(String isoCode); + + List findAllByAddressCountry(String country); + + List findAllByAddress_Country(String country); + + List findAllByAddress_CountryIsoCode(String isoCode); + + List findAllByAddress_Country_IsoCode(String isoCode); + + long countCustomerByAddressCountryName(String name); + + long countCustomerByAddress_ZipCode(String zipCode); + +} diff --git a/extensions/spring-data-jpa/deployment/src/test/java/io/quarkus/spring/data/deployment/CustomerRepositoryDerivedMethodsTest.java b/extensions/spring-data-jpa/deployment/src/test/java/io/quarkus/spring/data/deployment/CustomerRepositoryDerivedMethodsTest.java new file mode 100644 index 0000000000000..19abad3c38b02 --- /dev/null +++ b/extensions/spring-data-jpa/deployment/src/test/java/io/quarkus/spring/data/deployment/CustomerRepositoryDerivedMethodsTest.java @@ -0,0 +1,237 @@ +package io.quarkus.spring.data.deployment; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.time.ZonedDateTime; +import java.util.Arrays; +import java.util.List; + +import jakarta.transaction.Transactional; + +import org.jboss.shrinkwrap.api.ShrinkWrap; +import org.jboss.shrinkwrap.api.spec.JavaArchive; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.springframework.beans.factory.annotation.Autowired; + +import io.quarkus.test.QuarkusUnitTest; + +class CustomerRepositoryDerivedMethodsTest { + + @RegisterExtension + static final QuarkusUnitTest TEST = new QuarkusUnitTest().setArchiveProducer( + () -> ShrinkWrap.create(JavaArchive.class) + .addAsResource("import_customers.sql", "import.sql") + .addClasses(Customer.class, CustomerRepository.class)) + .withConfigurationResource("application.properties"); + + private static final ZonedDateTime BIRTHDATE = ZonedDateTime.now(); + + @Autowired + private CustomerRepository customerRepository; + + @Test + @Transactional + void whenFindByNameThenReturnsCorrectResult() { + + assertEquals(2, customerRepository.findByName("Adam") + .size()); + + assertEquals(2, customerRepository.findByNameIs("Adam") + .size()); + + assertEquals(2, customerRepository.findByNameEquals("Adam") + .size()); + + assertEquals(1, customerRepository.findByNameIsNull() + .size()); + assertEquals(5, customerRepository.findByNameIsNotNull() + .size()); + } + + @Test + @Transactional + void whenFindingByNameNotAdamThenReturnsCorrectResult() { + + assertEquals(3, customerRepository.findByNameNot("Adam") + .size()); + assertEquals(3, customerRepository.findByNameIsNot("Adam") + .size()); + + } + + @Test + @Transactional + void whenFindByNameStartingWith_thenReturnsCorrectResult() { + + assertEquals(2, customerRepository.findByNameStartingWith("A") + .size()); + } + + @Test + @Transactional + void whenFindByNameLikePatternThenReturnsCorrectResult() { + + assertEquals(2, customerRepository.findByNameLike("%im%") + .size()); + } + + @Test + @Transactional + void whenFindByNameEndingWith_thenReturnsCorrectResult() { + + assertEquals(2, customerRepository.findByNameEndingWith("e") + .size()); + } + + @Test + @Transactional + void whenByNameContaining_thenReturnsCorrectResult() { + + assertEquals(1, customerRepository.findByNameContaining("v") + .size()); + } + + @Test + @Transactional + void whenFindingByNameEndingWithMThenReturnsThree() { + + assertEquals(3, customerRepository.findByNameEndingWith("m") + .size()); + } + + @Test + @Transactional + void whenByAgeLessThanThenReturnsCorrectResult() { + + assertEquals(3, customerRepository.findByAgeLessThan(25) + .size()); + } + + @Test + @Transactional + void whenByAgeLessThanEqualThenReturnsCorrectResult() { + + assertEquals(4, customerRepository.findByAgeLessThanEqual(25) + .size()); + } + + @Test + @Transactional + void whenByAgeGreaterThan25ThenReturnsTwo() { + assertEquals(2, customerRepository.findByAgeGreaterThan(25) + .size()); + } + + @Test + @Transactional + void whenFindingByAgeGreaterThanEqual25ThenReturnsThree() { + + assertEquals(3, customerRepository.findByAgeGreaterThanEqual(25) + .size()); + } + + @Test + @Transactional + void whenFindingByAgeBetween20And30ThenReturnsFour() { + + assertEquals(4, customerRepository.findByAgeBetween(20, 30) + .size()); + } + + @Test + @Transactional + void whenFindingByBirthDateAfterYesterdayThenReturnsCorrectResult() { + + final ZonedDateTime yesterday = BIRTHDATE.minusDays(1); + assertEquals(6, customerRepository.findByBirthDateAfter(yesterday) + .size()); + } + + @Test + @Transactional + void whenByBirthDateBeforeThenReturnsCorrectResult() { + + final ZonedDateTime yesterday = BIRTHDATE.minusDays(1); + assertEquals(0, customerRepository.findByBirthDateBefore(yesterday) + .size()); + } + + @Test + @Transactional + void whenByActiveTrueThenReturnsCorrectResult() { + + assertEquals(2, customerRepository.findByActiveTrue() + .size()); + } + + @Test + @Transactional + void whenByActiveFalseThenReturnsCorrectResult() { + + assertEquals(4, customerRepository.findByActiveFalse() + .size()); + } + + @Test + @Transactional + void whenByAgeInThenReturnsCorrectResult() { + + final List ages = Arrays.asList(20, 25); + assertEquals(3, customerRepository.findByAgeIn(ages) + .size()); + } + + @Test + @Transactional + void whenByNameOrAge() { + + assertEquals(3, customerRepository.findByNameOrAge("Adam", 20) + .size()); + } + + @Test + @Transactional + void whenByNameOrAgeAndActive() { + + assertEquals(2, customerRepository.findByNameOrAgeAndActive("Adam", 20, false) + .size()); + } + + @Test + @Transactional + void whenByNameAndAgeAndActive() { + + assertEquals(1, customerRepository.findAllByNameAndAgeAndActive("Adam", 20, false) + .size()); + } + + @Test + @Transactional + void whenByNameOrAgeOrActive() { + + assertEquals(3, customerRepository.findAllByNameOrAgeOrActive("Adam", 20, true) + .size()); + } + + @Test + @Transactional + void whenByNameAndAgeOrActive() { + + assertEquals(3, customerRepository.findAllByNameAndAgeOrActive("Adam", 20, true) + .size()); + } + + @Test + @Transactional + void whenByNameOrderByName() { + + assertEquals(2, customerRepository.findByNameOrderByName("Adam") + .size()); + assertEquals(2, customerRepository.findByNameOrderByNameDesc("Adam") + .size()); + assertEquals(2, customerRepository.findByNameOrderByNameAsc("Adam") + .size()); + } + +} diff --git a/extensions/spring-data-jpa/deployment/src/test/java/io/quarkus/spring/data/deployment/MethodNameParserTest.java b/extensions/spring-data-jpa/deployment/src/test/java/io/quarkus/spring/data/deployment/MethodNameParserTest.java index 6c3b27f163254..e678443ac8e3c 100644 --- a/extensions/spring-data-jpa/deployment/src/test/java/io/quarkus/spring/data/deployment/MethodNameParserTest.java +++ b/extensions/spring-data-jpa/deployment/src/test/java/io/quarkus/spring/data/deployment/MethodNameParserTest.java @@ -17,9 +17,9 @@ import org.jboss.jandex.MethodInfo; import org.junit.jupiter.api.Test; -import io.quarkus.spring.data.deployment.generics.ChildBase; -import io.quarkus.spring.data.deployment.generics.ParentBase; -import io.quarkus.spring.data.deployment.generics.ParentBaseRepository; +import io.quarkus.spring.data.deployment.nested.fields.generics.ChildBase; +import io.quarkus.spring.data.deployment.nested.fields.generics.ParentBase; +import io.quarkus.spring.data.deployment.nested.fields.generics.ParentBaseRepository; public class MethodNameParserTest { @@ -33,8 +33,8 @@ public void testFindAllByAddressZipCode() throws Exception { additionalClasses); assertThat(result).isNotNull(); assertSameClass(result.getEntityClass(), entityClass); - assertThat(result.getQuery()).isEqualTo("FROM Person WHERE address.zipCode = ?1"); assertThat(result.getParamCount()).isEqualTo(1); + assertThat(result.getQuery()).isEqualTo("FROM Person AS person WHERE address.zipCode = ?1"); } @Test @@ -43,17 +43,47 @@ public void testFindAllByAddressCountry() throws Exception { additionalClasses); assertThat(result).isNotNull(); assertSameClass(result.getEntityClass(), entityClass); - assertThat(result.getQuery()).isEqualTo("FROM Person WHERE addressCountry = ?1"); + assertThat(result.getQuery()).isEqualTo("FROM Person AS person WHERE addressCountry = ?1"); assertThat(result.getParamCount()).isEqualTo(1); } + @Test + public void findAllByNameOrAgeOrActive() throws Exception { + MethodNameParser.Result result = parseMethod(repositoryClass, "findAllByNameOrAgeOrActive", entityClass, + additionalClasses); + assertThat(result).isNotNull(); + assertSameClass(result.getEntityClass(), entityClass); + assertThat(result.getQuery()).isEqualTo("FROM Person AS person WHERE name = ?1 OR age = ?2 OR active = ?3"); + assertThat(result.getParamCount()).isEqualTo(3); + } + + @Test + public void findAllByNameAndAgeOrActive() throws Exception { + MethodNameParser.Result result = parseMethod(repositoryClass, "findAllByNameAndAgeOrActive", entityClass, + additionalClasses); + assertThat(result).isNotNull(); + assertSameClass(result.getEntityClass(), entityClass); + assertThat(result.getQuery()).isEqualTo("FROM Person AS person WHERE name = ?1 AND age = ?2 OR active = ?3"); + assertThat(result.getParamCount()).isEqualTo(3); + } + + @Test + public void findAllByNameAndAgeAndActive() throws Exception { + MethodNameParser.Result result = parseMethod(repositoryClass, "findAllByNameAndAgeAndActive", entityClass, + additionalClasses); + assertThat(result).isNotNull(); + assertSameClass(result.getEntityClass(), entityClass); + assertThat(result.getQuery()).isEqualTo("FROM Person AS person WHERE name = ?1 AND age = ?2 AND active = ?3"); + assertThat(result.getParamCount()).isEqualTo(3); + } + @Test public void testFindAllByAddress_Country() throws Exception { MethodNameParser.Result result = parseMethod(repositoryClass, "findAllByAddress_Country", entityClass, additionalClasses); assertThat(result).isNotNull(); assertSameClass(result.getEntityClass(), entityClass); - assertThat(result.getQuery()).isEqualTo("FROM Person WHERE address.country = ?1"); + assertThat(result.getQuery()).isEqualTo("FROM Person AS person WHERE address.country = ?1"); assertThat(result.getParamCount()).isEqualTo(1); } @@ -70,7 +100,7 @@ public void testFindAllByAddress_CountryIsoCode() throws Exception { additionalClasses); assertThat(result).isNotNull(); assertSameClass(result.getEntityClass(), entityClass); - assertThat(result.getQuery()).isEqualTo("FROM Person WHERE address.country.isoCode = ?1"); + assertThat(result.getQuery()).isEqualTo("FROM Person AS person WHERE address.country.isoCode = ?1"); assertThat(result.getParamCount()).isEqualTo(1); } @@ -80,7 +110,7 @@ public void testFindAllByAddress_Country_IsoCode() throws Exception { additionalClasses); assertThat(result).isNotNull(); assertSameClass(result.getEntityClass(), entityClass); - assertThat(result.getQuery()).isEqualTo("FROM Person WHERE address.country.isoCode = ?1"); + assertThat(result.getQuery()).isEqualTo("FROM Person AS person WHERE address.country.isoCode = ?1"); assertThat(result.getParamCount()).isEqualTo(1); } @@ -108,7 +138,7 @@ public void testGenericsWithWildcard() throws Exception { additionalClasses); assertThat(result).isNotNull(); assertSameClass(result.getEntityClass(), ParentBase.class); - assertThat(result.getQuery()).isEqualTo("FROM ParentBase WHERE children.nombre = ?1"); + assertThat(result.getQuery()).isEqualTo("FROM ParentBase AS parentbase WHERE children.nombre = ?1"); assertThat(result.getParamCount()).isEqualTo(1); } @@ -121,8 +151,9 @@ public void shouldParseRepositoryMethodOverEntityContainingACollection() throws additionalClasses); assertThat(result).isNotNull(); assertSameClass(result.getEntityClass(), User.class); - assertThat(result.getQuery()).isEqualTo("FROM User WHERE loginEvents.id = ?1"); assertThat(result.getParamCount()).isEqualTo(1); + assertThat(result.getQuery()).isEqualTo( + "FROM User AS user LEFT JOIN loginEvents loginEvents ON user.userId = loginEvents.user.userId WHERE loginEvents.id = ?1"); } private AbstractStringAssert assertSameClass(ClassInfo classInfo, Class aClass) { diff --git a/extensions/spring-data-jpa/deployment/src/test/java/io/quarkus/spring/data/deployment/Person.java b/extensions/spring-data-jpa/deployment/src/test/java/io/quarkus/spring/data/deployment/Person.java index e780ed933071d..7c3b04250a7b0 100644 --- a/extensions/spring-data-jpa/deployment/src/test/java/io/quarkus/spring/data/deployment/Person.java +++ b/extensions/spring-data-jpa/deployment/src/test/java/io/quarkus/spring/data/deployment/Person.java @@ -1,33 +1,129 @@ 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 Person { @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; private String addressCountry; + public Person() { + } + + public Person(String name, Integer age, ZonedDateTime birthDate, Boolean active) { + this.name = name; + this.age = age; + this.birthDate = birthDate; + this.active = active; + } + + 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; + } + + public Address getAddress() { + return address; + } + + public void setAddress(Address address) { + this.address = address; + } + + public String getAddressCountry() { + return addressCountry; + } + + public void setAddressCountry(String addressCountry) { + this.addressCountry = addressCountry; + } + @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() { + + } } } diff --git a/extensions/spring-data-jpa/deployment/src/test/java/io/quarkus/spring/data/deployment/PersonRepository.java b/extensions/spring-data-jpa/deployment/src/test/java/io/quarkus/spring/data/deployment/PersonRepository.java index 8f8f32c5f27a2..1492220aa70d2 100644 --- a/extensions/spring-data-jpa/deployment/src/test/java/io/quarkus/spring/data/deployment/PersonRepository.java +++ b/extensions/spring-data-jpa/deployment/src/test/java/io/quarkus/spring/data/deployment/PersonRepository.java @@ -4,13 +4,19 @@ import org.springframework.data.repository.Repository; -// issue 13067: +// issue 13067: This repo is used to test the MethodNameParser class. See MethodNameParserTest class public interface PersonRepository extends Repository { List findAllByAddressZipCode(String zipCode); List findAllByAddressCountry(String zipCode); + List findAllByNameAndAgeAndActive(String name, int age, boolean active); + + List findAllByNameAndAgeOrActive(String name, int age, boolean active); + + List findAllByNameOrAgeOrActive(String name, int age, boolean active); + List findAllByAddress_Country(String zipCode); List findAllByAddressCountryIsoCode(String zipCode); diff --git a/extensions/spring-data-jpa/deployment/src/test/java/io/quarkus/spring/data/deployment/nested/fields/CustomerRepositoryNestedFieldsDerivedMethodsTest.java b/extensions/spring-data-jpa/deployment/src/test/java/io/quarkus/spring/data/deployment/nested/fields/CustomerRepositoryNestedFieldsDerivedMethodsTest.java new file mode 100644 index 0000000000000..0fa1a81014d95 --- /dev/null +++ b/extensions/spring-data-jpa/deployment/src/test/java/io/quarkus/spring/data/deployment/nested/fields/CustomerRepositoryNestedFieldsDerivedMethodsTest.java @@ -0,0 +1,90 @@ +package io.quarkus.spring.data.deployment.nested.fields; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.List; +import java.util.stream.Collectors; + +import jakarta.transaction.Transactional; + +import org.hibernate.query.QueryArgumentException; +import org.jboss.shrinkwrap.api.ShrinkWrap; +import org.jboss.shrinkwrap.api.spec.JavaArchive; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.springframework.beans.factory.annotation.Autowired; + +import io.quarkus.spring.data.deployment.Customer; +import io.quarkus.spring.data.deployment.CustomerRepository; +import io.quarkus.test.QuarkusUnitTest; + +class CustomerRepositoryNestedFieldsDerivedMethodsTest { + + @RegisterExtension + static final QuarkusUnitTest TEST = new QuarkusUnitTest().setArchiveProducer( + () -> ShrinkWrap.create(JavaArchive.class) + .addAsResource("import_customers.sql", "import.sql") + .addClasses(Customer.class, CustomerRepository.class)) + .withConfigurationResource("application.properties"); + + @Autowired + private CustomerRepository customerRepository; + + @Test + @Transactional + void findByAddressZipCode() { + + List allByAddressZipCode = customerRepository.findAllByAddressZipCode("28004"); + assertEquals(2, allByAddressZipCode.size()); + assertThat(allByAddressZipCode.stream().map(c -> c.getName()).collect(Collectors.toList())) + .containsExactlyInAnyOrder("Adam", "Tim"); + } + + @Test + @Transactional + void findByAddressCountryIsoCode() { + + assertEquals(2, customerRepository.findAllByAddressCountryIsoCode("ES") + .size()); + + assertEquals(2, customerRepository.findAllByAddress_CountryIsoCode("ES") + .size()); + + assertEquals(2, customerRepository.findAllByAddress_Country_IsoCode("ES") + .size()); + } + + @Test + @Transactional + void findByAddressCountry() { + + QueryArgumentException exception = assertThrows(QueryArgumentException.class, + () -> customerRepository.findAllByAddressCountry("Spain")); + assertThat(exception).hasMessageContaining("Argument [Spain] of type [java.lang.String] did not match parameter type"); + assertThrows(QueryArgumentException.class, + () -> customerRepository.findAllByAddress_Country("Spain")); + assertThat(exception).hasMessageContaining("Argument [Spain] of type [java.lang.String] did not match parameter type"); + + } + + @Test + @Transactional + void shouldCountSpanishCustomers() { + + long spanishCustomers = customerRepository.countCustomerByAddressCountryName("Spain"); + Assertions.assertEquals(2, spanishCustomers); + + } + + @Test + @Transactional + void shouldCountCustomersByZipCode() { + + long spanishCustomers = customerRepository.countCustomerByAddress_ZipCode("28004"); + Assertions.assertEquals(2, spanishCustomers); + + } +} diff --git a/extensions/spring-data-jpa/deployment/src/test/java/io/quarkus/spring/data/deployment/generics/ChildBase.java b/extensions/spring-data-jpa/deployment/src/test/java/io/quarkus/spring/data/deployment/nested/fields/generics/ChildBase.java similarity index 66% rename from extensions/spring-data-jpa/deployment/src/test/java/io/quarkus/spring/data/deployment/generics/ChildBase.java rename to extensions/spring-data-jpa/deployment/src/test/java/io/quarkus/spring/data/deployment/nested/fields/generics/ChildBase.java index 670487953ff85..0d1f74b03e0b2 100644 --- a/extensions/spring-data-jpa/deployment/src/test/java/io/quarkus/spring/data/deployment/generics/ChildBase.java +++ b/extensions/spring-data-jpa/deployment/src/test/java/io/quarkus/spring/data/deployment/nested/fields/generics/ChildBase.java @@ -1,4 +1,4 @@ -package io.quarkus.spring.data.deployment.generics; +package io.quarkus.spring.data.deployment.nested.fields.generics; import jakarta.persistence.MappedSuperclass; diff --git a/extensions/spring-data-jpa/deployment/src/test/java/io/quarkus/spring/data/deployment/generics/ParentBase.java b/extensions/spring-data-jpa/deployment/src/test/java/io/quarkus/spring/data/deployment/nested/fields/generics/ParentBase.java similarity index 85% rename from extensions/spring-data-jpa/deployment/src/test/java/io/quarkus/spring/data/deployment/generics/ParentBase.java rename to extensions/spring-data-jpa/deployment/src/test/java/io/quarkus/spring/data/deployment/nested/fields/generics/ParentBase.java index efb76254359bb..8f1d7459704f1 100644 --- a/extensions/spring-data-jpa/deployment/src/test/java/io/quarkus/spring/data/deployment/generics/ParentBase.java +++ b/extensions/spring-data-jpa/deployment/src/test/java/io/quarkus/spring/data/deployment/nested/fields/generics/ParentBase.java @@ -1,4 +1,4 @@ -package io.quarkus.spring.data.deployment.generics; +package io.quarkus.spring.data.deployment.nested.fields.generics; import java.util.List; diff --git a/extensions/spring-data-jpa/deployment/src/test/java/io/quarkus/spring/data/deployment/generics/ParentBaseRepository.java b/extensions/spring-data-jpa/deployment/src/test/java/io/quarkus/spring/data/deployment/nested/fields/generics/ParentBaseRepository.java similarity index 63% rename from extensions/spring-data-jpa/deployment/src/test/java/io/quarkus/spring/data/deployment/generics/ParentBaseRepository.java rename to extensions/spring-data-jpa/deployment/src/test/java/io/quarkus/spring/data/deployment/nested/fields/generics/ParentBaseRepository.java index bb12c2c038cd1..480e6a66634d6 100644 --- a/extensions/spring-data-jpa/deployment/src/test/java/io/quarkus/spring/data/deployment/generics/ParentBaseRepository.java +++ b/extensions/spring-data-jpa/deployment/src/test/java/io/quarkus/spring/data/deployment/nested/fields/generics/ParentBaseRepository.java @@ -1,7 +1,8 @@ -package io.quarkus.spring.data.deployment.generics; +package io.quarkus.spring.data.deployment.nested.fields.generics; import org.springframework.data.jpa.repository.JpaRepository; +// Issue 34395: this repo is used in MethodNameParserTest public interface ParentBaseRepository> extends JpaRepository { long countParentsByChildren_Nombre(String name); } diff --git a/extensions/spring-data-jpa/deployment/src/test/resources/import_customers.sql b/extensions/spring-data-jpa/deployment/src/test/resources/import_customers.sql new file mode 100644 index 0000000000000..d0af8e6f20fe9 --- /dev/null +++ b/extensions/spring-data-jpa/deployment/src/test/resources/import_customers.sql @@ -0,0 +1,27 @@ +-- ALTER TABLE "User$Address" RENAME TO address; +-- ALTER TABLE "User$Country" RENAME TO country; + +INSERT INTO "Customer$Country" (id, name, isocode) +VALUES + (1, 'United States', 'US'), + (2, 'France', 'FR'), + (3, 'Germany', 'DE'), + (4, 'Spain', 'ES'); + +-- Insert sample data for Address +INSERT INTO "Customer$Address" (id, zipcode, country_id) +VALUES + (1, '10001', 1), -- US address + (2, '75001', 2), -- French address + (3, '10115', 3), -- German address + (4, '28004', 4); -- Spanish address + +-- Insert sample data for User +INSERT INTO Customer (id, name, age, birthdate, active, address_id) +VALUES + (1,'Adam', 25, now(), TRUE,4), + (2,'Adam', 20, now(), FALSE, 2), + (3,'Eve', 20, now(), TRUE, 1), + (4,null, 30, now(), FALSE,3), + (5,'Tim', 38, now(), FALSE, 4), + (6,'Timothée', 19, now(), FALSE, 2); \ No newline at end of file