Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support underscore in repo methods to avoid mapped properties ambiguity #2498

Merged
merged 2 commits into from
Sep 8, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ class MultiManyToOneJoinSpec extends Specification implements H2TestPropertyProv
@Inject
CustomBookRepository customBookRepository = applicationContext.getBean(CustomBookRepository)

@Shared
@Inject
UserGroupMembershipRepository userGroupMembershipRepository = applicationContext.getBean(UserGroupMembershipRepository)

void 'test many-to-one hierarchy'() {
given:
RefA refA = new RefA(refB: new RefB(refC: new RefC(name: "TestXyz")))
Expand Down Expand Up @@ -78,6 +82,28 @@ class MultiManyToOneJoinSpec extends Specification implements H2TestPropertyProv
cleanup:
customBookRepository.deleteAll()
}

void "test many to one with two properties starting with same prefix"() {
given:
def user = new User(login: "login1")
def area = new Area(name: "area51")
def userGroup = new UserGroup(area: area)
def userGroupMembership = new UserGroupMembership(user: user, userGroup: userGroup)
userGroup.getUserAuthorizations().add(userGroupMembership)
when:
userGroupMembershipRepository.save(userGroupMembership)
def listByUserLogin = userGroupMembershipRepository.findAllByUserLogin(user.login)
def listByUserLoginAndAreaId = userGroupMembershipRepository.findAllByUserLoginAndUserGroup_AreaId(user.login, area.id)
then:
listByUserLogin
listByUserLoginAndAreaId
listByUserLogin == listByUserLoginAndAreaId
listByUserLogin[0].userGroup.id == userGroup.id
listByUserLogin[0].user.id == user.id
listByUserLoginAndAreaId[0].userGroup.id == userGroup.id
listByUserLoginAndAreaId[0].user.id == user.id

}
}

@JdbcRepository(dialect = Dialect.H2)
Expand Down Expand Up @@ -164,3 +190,70 @@ class CustomBook {
CustomAuthor getAuthor() { return author }
void setAuthor(CustomAuthor author) { this.author = author }
}

@MappedEntity(value = "ugm", alias = "ugm_")
class UserGroupMembership {

@Id
@GeneratedValue
Long id

@Relation(value = Relation.Kind.MANY_TO_ONE, cascade = Relation.Cascade.PERSIST)
UserGroup userGroup

@Relation(value = Relation.Kind.MANY_TO_ONE, cascade = Relation.Cascade.PERSIST)
User user

@Override
int hashCode() {
return Objects.hash(id)
}

@Override
boolean equals(Object obj) {
if (obj instanceof UserGroupMembership) {
UserGroupMembership other = (UserGroupMembership) obj
return Objects.equals(id, other.id)
}
return false
}
}
@MappedEntity(value = "ug", alias = "ug_")
class UserGroup {

@Id
@GeneratedValue
Long id

@Relation(value = Relation.Kind.ONE_TO_MANY, mappedBy = "userGroup")
Set<UserGroupMembership> userAuthorizations = new HashSet<UserGroupMembership>()

@Relation(value = Relation.Kind.MANY_TO_ONE, cascade = Relation.Cascade.PERSIST)
Area area
}
@MappedEntity(value = "a", alias = "a_")
class Area {

@Id
@GeneratedValue
Long id

String name
}
@MappedEntity(value = "u", alias = "u_")
class User {

@Id
@GeneratedValue
Long id

String login
}
@JdbcRepository(dialect = Dialect.H2)
interface UserGroupMembershipRepository extends CrudRepository<UserGroupMembership, Long> {

List<UserGroupMembership> findAllByUserLogin(String login)

@Join(value = "userGroup.area", type = Join.Type.FETCH)
List<UserGroupMembership> findAllByUserLoginAndUserGroup_AreaId(String login, Long uid)
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,15 @@
import io.micronaut.core.annotation.AnnotationMetadata;
import io.micronaut.core.annotation.AnnotationValue;
import io.micronaut.core.annotation.Internal;
import io.micronaut.core.naming.NameUtils;
import io.micronaut.data.annotation.sql.JoinColumn;
import io.micronaut.data.annotation.sql.JoinColumns;

import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.function.BiConsumer;

/**
Expand All @@ -36,6 +38,8 @@
@Internal
public final class PersistentEntityUtils {

private static final String UNDERSCORE = "_";

private PersistentEntityUtils() {
}

Expand Down Expand Up @@ -126,6 +130,37 @@ public static void traversePersistentProperties(List<Association> associations,
}
}

/**
* Computes a dot separated property path for the given camel case path.
*
* @param path The camel case path, can contain underscore to indicate how we should traverse entity properties
* @param entity the persistent entity
* @return The dot separated version or null if it cannot be computed
*/
public static Optional<String> getPersistentPropertyPath(PersistentEntity entity, String path) {
String decapitalizedPath = NameUtils.decapitalize(path);
if (entity.getPropertyByName(decapitalizedPath) != null) {
// First try to see if there is direct property on the entity
return Optional.of(decapitalizedPath);
}
// Then see if path contains underscore to indicate which paths/entities to lookup
String[] entityPaths = path.split(UNDERSCORE);
if (entityPaths.length > 1) {
String assocPath = entityPaths[0];
PersistentProperty pp = entity.getPropertyByName(assocPath);
if (pp instanceof Association assoc) {
PersistentEntity assocEntity = assoc.getAssociatedEntity();
String restPath = path.replaceFirst(assocPath + UNDERSCORE, "");
Optional<String> tailPath = getPersistentPropertyPath(assocEntity, restPath);
if (tailPath.isPresent()) {
return Optional.of(assocPath + "." + tailPath.get());
}
throw new IllegalArgumentException("Invalid path [" + restPath + "] of [" + assocEntity + "]");
}
}
return entity.getPath(path);
}

private static PersistentProperty getJoinColumnAssocIdentity(PersistentProperty property, PersistentEntity associatedEntity) {
AnnotationMetadata propertyAnnotationMetadata = property.getAnnotationMetadata();
AnnotationValue<JoinColumns> joinColumnsAnnotationValue = propertyAnnotationMetadata.getAnnotation(JoinColumns.class);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
import io.micronaut.data.intercept.annotation.DataMethod;
import io.micronaut.data.model.Association;
import io.micronaut.data.model.PersistentEntity;
import io.micronaut.data.model.PersistentEntityUtils;
import io.micronaut.data.model.PersistentProperty;
import io.micronaut.data.model.PersistentPropertyPath;
import io.micronaut.data.model.jpa.criteria.PersistentEntityCriteriaDelete;
Expand Down Expand Up @@ -606,7 +607,7 @@ protected final <T> io.micronaut.data.model.jpa.criteria.PersistentPropertyPath<
PersistentProperty prop = entity.getPropertyByName(propertyName);
PersistentPropertyPath pp;
if (prop == null) {
Optional<String> propertyPath = entity.getPath(propertyName);
Optional<String> propertyPath = PersistentEntityUtils.getPersistentPropertyPath(entity, propertyName);
if (propertyPath.isPresent()) {
String path = propertyPath.get();
pp = entity.getPropertyPath(path);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import io.micronaut.core.annotation.NonNull;
import io.micronaut.core.annotation.Nullable;
import io.micronaut.core.naming.NameUtils;
import io.micronaut.data.model.PersistentEntityUtils;
import io.micronaut.data.model.jpa.criteria.PersistentEntityCriteriaBuilder;
import io.micronaut.data.model.jpa.criteria.PersistentEntityRoot;
import io.micronaut.data.model.jpa.criteria.PersistentPropertyPath;
Expand Down Expand Up @@ -57,7 +58,7 @@ public static Selection<?> find(@NonNull PersistentEntityRoot<?> entityRoot,
String value,
BiFunction<PersistentEntityRoot<?>, String, PersistentPropertyPath<?>> findFunction) {
String decapitalized = NameUtils.decapitalize(value);
Optional<String> path = entityRoot.getPersistentEntity().getPath(decapitalized);
Optional<String> path = PersistentEntityUtils.getPersistentPropertyPath(entityRoot.getPersistentEntity(), decapitalized);
if (path.isPresent()) {
return entityRoot.get(path.get());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -880,4 +880,148 @@ interface AuthorRepository extends GenericRepository<Author, Long> {
expect:
queryAllQuery == 'SELECT author_.`id`,author_books_.`id` AS books_id,author_books_.`author_id` AS books_author_id,author_books_.`genre_id` AS books_genre_id,author_books_.`title` AS books_title,author_books_.`total_pages` AS books_total_pages,author_books_.`publisher_id` AS books_publisher_id,author_books_.`last_updated` AS books_last_updated FROM `author` author_ INNER JOIN `book` author_books_ ON author_.`id`=author_books_.`author_id`'
}

void "test many-to-one with properties starting with the same prefix"() {
given:
def repository = buildRepository('test.UserGroupMembershipRepository', """

import io.micronaut.data.annotation.Id;
import io.micronaut.data.annotation.Join;
import io.micronaut.data.annotation.MappedEntity;
import io.micronaut.data.jdbc.annotation.JdbcRepository;
import io.micronaut.data.model.query.builder.sql.Dialect;
import io.micronaut.data.repository.GenericRepository;
import jakarta.persistence.FetchType;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany;

@MappedEntity(value = "ua", alias = "ua_")
class Address {
@Id
private Long id;
private String zipCode;
private String city;
private String street;
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getZipCode() { return zipCode; }
public void setZipCode(String zipCode) { this.zipCode = zipCode; }
public String getCity() { return city; }
public void setCity(String city) { this.city = city; }
public String getStreet() { return street; }
public void setStreet(String street) { this.street = street; }
}
@MappedEntity(value = "u", alias = "u_")
class User {
@Id
private Long id;
private String login;
private Address address;
private String addressZipCode;
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
public Address getAddress() { return address; }
public void setAddress(Address address) { this.address = address; }
public String getAddressZipCode() { return addressZipCode; }
public void setAddressZipCode(String addressZipCode) { this.addressZipCode = addressZipCode; }
}
@MappedEntity(value = "a", alias = "a_")
class Area {
@Id
private Long id;
private String name;
public Long getId() { return id;}
public void setId(Long id) { this.id = id; }
public String getName() { return name;}
public void setName(String name) { this.name = name; }
}
@MappedEntity(value = "ug", alias = "ug_")
class UserGroup {
@Id
private Long id;
@OneToMany(mappedBy = "userGroup", fetch = FetchType.LAZY)
private Set<UserGroupMembership> userAuthorizations = new HashSet<>();
@ManyToOne
private Area area;
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public Set<UserGroupMembership> getUserAuthorizations() { return userAuthorizations; }
public void setUserAuthorizations(Set<UserGroupMembership> userAuthorizations) { this.userAuthorizations = userAuthorizations; }
public Area getArea() { return area; }
public void setArea(Area area) { this.area = area; }
}
@MappedEntity(value = "ugm", alias = "ugm_")
class UserGroupMembership {
@Id
private Long id;
@ManyToOne(fetch = FetchType.EAGER)
private UserGroup userGroup;
@ManyToOne(fetch = FetchType.EAGER)
private User user;
@ManyToOne
private Address userAddress;
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public UserGroup getUserGroup() { return userGroup; }
public void setUserGroup(UserGroup userGroup) { this.userGroup = userGroup; }
public User getUser() { return user; }
public void setUser(User user) { this.user = user; }
public Address getUserAddress() { return userAddress; }
public void setUserAddress(Address userAddress) { this.userAddress = userAddress; }
}
@JdbcRepository(dialect = Dialect.MYSQL)
interface UserGroupMembershipRepository extends GenericRepository<UserGroupMembership, Long> {

@Join(value = "userGroup.area", type = Join.Type.FETCH)
List<UserGroupMembership> findAllByUserLoginAndUserGroup_AreaId(String login, Long uid);

List<UserGroupMembership> findAllByUserLogin(String userLogin);

List<UserGroupMembership> findAllByUser_AddressZipCode(String zipCode);

List<UserGroupMembership> findAllByUserAddress_ZipCode(String zipCode);
}
"""
)

expect:"The repository to compile"
repository != null
when:
def queryByUserLoginAndAreaId = getQuery(repository.getRequiredMethod("findAllByUserLoginAndUserGroup_AreaId", String, Long))
def queryByUserLogin = getQuery(repository.getRequiredMethod("findAllByUserLogin", String))
def queryByUserAddressZipCode = getQuery(repository.getRequiredMethod("findAllByUser_AddressZipCode", String))
def queryByUserAddressZipCode2 = getQuery(repository.getRequiredMethod("findAllByUserAddress_ZipCode", String))
then:
queryByUserLoginAndAreaId != ''
queryByUserLogin == 'SELECT ugm_.`id`,ugm_.`user_group_id`,ugm_.`user_id`,ugm_.`user_address_id` FROM `ugm` ugm_ INNER JOIN `u` ugm_user_ ON ugm_.`user_id`=ugm_user_.`id` WHERE (ugm_user_.`login` = ?)'
// Queries by user.addressZipCode
queryByUserAddressZipCode == 'SELECT ugm_.`id`,ugm_.`user_group_id`,ugm_.`user_id`,ugm_.`user_address_id` FROM `ugm` ugm_ INNER JOIN `u` ugm_user_ ON ugm_.`user_id`=ugm_user_.`id` WHERE (ugm_user_.`address_zip_code` = ?)'
// Queries by userAddress.zipCode
queryByUserAddressZipCode2 == 'SELECT ugm_.`id`,ugm_.`user_group_id`,ugm_.`user_id`,ugm_.`user_address_id` FROM `ugm` ugm_ INNER JOIN `ua` ugm_user_address_ ON ugm_.`user_address_id`=ugm_user_address_.`id` WHERE (ugm_user_address_.`zip_code` = ?)'
}

void "test repo method with underscore and not matching property"() {
when:
def repository = buildRepository('test.BookRepository', """

import io.micronaut.data.annotation.Join;
import io.micronaut.data.jdbc.annotation.JdbcRepository;
import io.micronaut.data.model.query.builder.sql.Dialect;
import io.micronaut.data.tck.entities.Book;

@JdbcRepository(dialect = Dialect.MYSQL)
interface BookRepository extends GenericRepository<Book, Long> {

@Join(value = "author", type = Join.Type.FETCH)
List<UserGroupMembership> findAllByPublisherZipCodeAndAuthor_SpecName(String zipCode, String specName);
}
"""
)

then:
Throwable ex = thrown()
ex.message.contains('Invalid path [SpecName] of [io.micronaut.data.tck.entities.Author]')
}
}