diff --git a/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/h2/H2RepositorySpec.groovy b/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/h2/H2RepositorySpec.groovy index 7c2bcf2efd..46670ea7aa 100644 --- a/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/h2/H2RepositorySpec.groovy +++ b/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/h2/H2RepositorySpec.groovy @@ -16,6 +16,9 @@ package io.micronaut.data.jdbc.h2 import io.micronaut.data.tck.entities.Car +import io.micronaut.data.tck.entities.Face +import io.micronaut.data.tck.entities.FaceDTO +import io.micronaut.data.tck.entities.Nose import io.micronaut.data.tck.repositories.AuthorRepository import io.micronaut.data.tck.repositories.BookDtoRepository import io.micronaut.data.tck.repositories.BookRepository diff --git a/data-model/src/main/java/io/micronaut/data/model/query/QueryModel.java b/data-model/src/main/java/io/micronaut/data/model/query/QueryModel.java index 647b0cc3ff..07f98b03ac 100644 --- a/data-model/src/main/java/io/micronaut/data/model/query/QueryModel.java +++ b/data-model/src/main/java/io/micronaut/data/model/query/QueryModel.java @@ -1330,6 +1330,9 @@ class PropertyProjection extends Projection { private String propertyName; private String alias; + @Nullable + private String entityAlias; + /** * Default constructor. * @param propertyName The property name @@ -1338,6 +1341,29 @@ public PropertyProjection(String propertyName) { this.propertyName = propertyName; } + /** + * constructor. + * @param entityAlias Entity Alias + * @param propertyName The property name + * @param alias the alias + */ + public PropertyProjection(@Nullable String entityAlias, + String propertyName, + String alias) { + this.entityAlias = entityAlias; + this.propertyName = propertyName; + this.alias = alias; + } + + /** + * + * @return The alias of the entity the property belongs to. + */ + @Nullable + public String getEntityAlias() { + return entityAlias; + } + /** * @return The property name */ diff --git a/data-model/src/main/java/io/micronaut/data/model/query/builder/AbstractSqlLikeQueryBuilder.java b/data-model/src/main/java/io/micronaut/data/model/query/builder/AbstractSqlLikeQueryBuilder.java index 44c39f773b..1378cfdc05 100644 --- a/data-model/src/main/java/io/micronaut/data/model/query/builder/AbstractSqlLikeQueryBuilder.java +++ b/data-model/src/main/java/io/micronaut/data/model/query/builder/AbstractSqlLikeQueryBuilder.java @@ -55,7 +55,7 @@ public abstract class AbstractSqlLikeQueryBuilder implements QueryBuilder { protected static final char DOT = '.'; protected static final String NOT_CLAUSE = " NOT"; protected static final String AND = "AND"; - protected static final String LOGICAL_AND = " "+ AND + " "; + protected static final String LOGICAL_AND = " " + AND + " "; protected static final String UPDATE_CLAUSE = "UPDATE "; protected static final String DELETE_CLAUSE = "DELETE "; protected static final String OR = "OR"; @@ -727,7 +727,8 @@ private void buildSelect(QueryState queryState, StringBuilder queryString, List< String propertyName = pp.getPropertyName(); PersistentProperty persistentProperty = entity.getPropertyByPath(propertyName) .orElseThrow(() -> new IllegalArgumentException("Cannot project on non-existent property: " + propertyName)); - appendPropertyProjection(queryString, logicalName, persistentProperty, propertyName); + String entityAlias = pp.getEntityAlias() != null ? pp.getEntityAlias() : logicalName; + appendPropertyProjection(queryString, entityAlias, persistentProperty, propertyName); } if (alias != null) { queryString.append(AS_CLAUSE) diff --git a/data-model/src/main/java/io/micronaut/data/model/query/factory/Projections.java b/data-model/src/main/java/io/micronaut/data/model/query/factory/Projections.java index 105a5a9948..7d2e7b2c40 100644 --- a/data-model/src/main/java/io/micronaut/data/model/query/factory/Projections.java +++ b/data-model/src/main/java/io/micronaut/data/model/query/factory/Projections.java @@ -54,6 +54,17 @@ public static QueryModel.PropertyProjection property(String name) { return new QueryModel.PropertyProjection(name); } + /** + * A projection that obtains the value of a property of an entity. + * @param entityAlias Entity Alias + * @param name The name of the property + * @param alias alias + * @return The PropertyProjection instance + */ + public static QueryModel.PropertyProjection property(String entityAlias, String name, String alias) { + return new QueryModel.PropertyProjection(entityAlias, name, alias); + } + /** * Computes the sum of a property. * diff --git a/data-processor/src/main/java/io/micronaut/data/processor/visitors/finders/AbstractListMethod.java b/data-processor/src/main/java/io/micronaut/data/processor/visitors/finders/AbstractListMethod.java index 99a0e5baab..ed04cecd51 100644 --- a/data-processor/src/main/java/io/micronaut/data/processor/visitors/finders/AbstractListMethod.java +++ b/data-processor/src/main/java/io/micronaut/data/processor/visitors/finders/AbstractListMethod.java @@ -17,17 +17,15 @@ import edu.umd.cs.findbugs.annotations.NonNull; import edu.umd.cs.findbugs.annotations.Nullable; -import io.micronaut.core.annotation.AnnotationValue; import io.micronaut.core.util.CollectionUtils; import io.micronaut.core.util.StringUtils; -import io.micronaut.data.annotation.Join; import io.micronaut.data.annotation.Query; import io.micronaut.data.annotation.Where; import io.micronaut.data.model.PersistentEntity; import io.micronaut.data.model.PersistentProperty; +import io.micronaut.data.model.Sort; import io.micronaut.data.model.query.QueryModel; import io.micronaut.data.model.query.QueryParameter; -import io.micronaut.data.model.Sort; import io.micronaut.data.processor.model.SourcePersistentEntity; import io.micronaut.data.processor.model.SourcePersistentProperty; import io.micronaut.data.processor.visitors.AnnotationMetadataHierarchy; @@ -39,6 +37,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Optional; +import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -139,13 +138,17 @@ public MethodMatchInfo buildMatchInfo(@NonNull MethodMatchContext matchContext) } } - List> joinSpecs = joinSpecsAtMatchContext(matchContext); - + Set joinSpecs = joinSpecsForMatchContext(matchContext); + ClassElement returnType = matchContext.getReturnType(); + boolean dto = isReturnTypeDto(returnType, queryResultType); + if (dto) { + joinSpecs.addAll(joinSpecsByPath(joinPathsForDto(returnType, queryResultType))); + } if (CollectionUtils.isNotEmpty(joinSpecs)) { if (query == null) { query = QueryModel.from(rootEntity); } - if (applyJoinSpecs(matchContext, query, rootEntity, joinSpecs)) { + if (applyJoinSpecifications(matchContext, query, rootEntity, joinSpecs)) { return null; } } diff --git a/data-processor/src/main/java/io/micronaut/data/processor/visitors/finders/AbstractPatternBasedMethod.java b/data-processor/src/main/java/io/micronaut/data/processor/visitors/finders/AbstractPatternBasedMethod.java index e796543fdd..53fced34f1 100644 --- a/data-processor/src/main/java/io/micronaut/data/processor/visitors/finders/AbstractPatternBasedMethod.java +++ b/data-processor/src/main/java/io/micronaut/data/processor/visitors/finders/AbstractPatternBasedMethod.java @@ -53,6 +53,7 @@ import java.util.concurrent.Future; import java.util.regex.Matcher; import java.util.regex.Pattern; +import java.util.stream.Collectors; import java.util.stream.Stream; /** @@ -62,11 +63,13 @@ * @since 1.1.0 */ public abstract class AbstractPatternBasedMethod implements MethodCandidate { + public static final String CAPITALIZED_ID = "Id"; private static final Pattern ORDER_BY_PATTERN = Pattern.compile("(.*)OrderBy([\\w\\d]+)"); private static final String DELETE = "delete"; private static final String UPDATE = "update"; private static final String VOID = "void"; + private static final Join.Type DEFAULT_JOIN_TYPE = Join.Type.FETCH; protected final Pattern pattern; /** @@ -83,6 +86,38 @@ public boolean isMethodMatch(@NonNull MethodElement methodElement, @NonNull Matc return pattern.matcher(methodElement.getName()).find(); } + /** + * + * @param dto DTO Class element + * @param query Query Class Element + * @return A set of Paths + */ + @NonNull + protected static Set joinPathsForDto(@NonNull ClassElement dto, @NonNull ClassElement query) { + Set paths = new HashSet<>(); + for (PropertyElement propertyElement : dto.getBeanProperties()) { + final String name = propertyElement.getName(); + if (name.endsWith(CAPITALIZED_ID)) { + String associationName = name.substring(0, name.indexOf(CAPITALIZED_ID)); + paths.add(associationName); + } + } + return paths; + } + /** + * + * @param returnType Return Type + * @param queryResultType Query Result Type + * @return Whether the return type is a DTO + */ + protected boolean isReturnTypeDto(@Nullable ClassElement returnType, @Nullable ClassElement queryResultType) { + return returnType != null && + queryResultType != null && + !TypeUtils.areTypesCompatible(returnType, queryResultType) && + returnType.hasStereotype(Introspected.class) && + queryResultType.hasStereotype(MappedEntity.class); + } + /** * Matches order by definitions in the query sequence. * @@ -188,7 +223,7 @@ protected MethodMatchInfo buildInfo( return null; } } - + return new MethodMatchInfo(returnType, query, getInterceptorElement(matchContext, FindOneInterceptor.class), true); } else { @@ -479,7 +514,10 @@ private boolean isReactiveSingleResult(ClassElement returnType) { return returnType.hasStereotype(SingleResult.class) || returnType.isAssignable("io.reactivex.Single") || returnType.isAssignable("reactor.core.publisher.Mono"); } - private boolean attemptProjection(@NonNull MethodMatchContext matchContext, @NonNull ClassElement queryResultType, @NonNull QueryModel query, ClassElement returnType) { + private boolean attemptProjection(@NonNull MethodMatchContext matchContext, + @NonNull ClassElement queryResultType, + @NonNull QueryModel query, + ClassElement returnType) { List beanProperties = returnType.getBeanProperties(); SourcePersistentEntity entity = matchContext.getEntity(queryResultType); for (PropertyElement beanProperty : beanProperties) { @@ -489,6 +527,18 @@ private boolean attemptProjection(@NonNull MethodMatchContext matchContext, @Non if (pp == null) { pp = entity.getIdOrVersionPropertyByName(propertyName); } + SourcePersistentEntity association = null; + if (pp == null && propertyName.endsWith(CAPITALIZED_ID)) { + String associationPropertyName = propertyName.substring(0, propertyName.indexOf(CAPITALIZED_ID)); + Optional associationOptional = entity.getPersistentProperties() + .stream() + .filter(persistentProperty -> persistentProperty.getName().equals(associationPropertyName)) + .findFirst(); + if (associationOptional.isPresent()) { + association = matchContext.getEntity(associationOptional.get().getType()); + pp = association.getIdentity(); + } + } if (pp == null) { matchContext.fail("Property " + propertyName + " is not present in entity: " + entity.getName()); @@ -501,10 +551,16 @@ private boolean attemptProjection(@NonNull MethodMatchContext matchContext, @Non } // add an alias projection for each property final QueryBuilder queryBuilder = matchContext.getQueryBuilder(); - if (queryBuilder.shouldAliasProjections()) { - query.projections().add(Projections.property(propertyName).aliased()); + + if (association == null) { + if (queryBuilder.shouldAliasProjections()) { + query.projections().add(Projections.property(propertyName).aliased()); + } else { + query.projections().add(Projections.property(propertyName)); + } } else { - query.projections().add(Projections.property(propertyName)); + String entityAlias = entity.getAliasName() + association.getAliasName(); + query.projections().add(Projections.property(entityAlias, pp.getName(), propertyName)); } } return false; @@ -561,28 +617,78 @@ protected boolean applyJoinSpecs( @NonNull QueryModel query, @Nonnull SourcePersistentEntity rootEntity, @NonNull List> joinSpecs) { - for (AnnotationValue joinSpec : joinSpecs) { - String path = joinSpec.stringValue().orElse(null); - Join.Type type = joinSpec.enumValue("type", Join.Type.class).orElse(Join.Type.FETCH); - String alias = joinSpec.stringValue("alias").orElse(null); - if (path != null) { - PersistentProperty prop = rootEntity.getPropertyByPath(path).orElse(null); - if (!(prop instanceof Association)) { - matchContext.fail("Invalid join spec [" + path + "]. Property is not an association!"); - return true; - } else { - boolean hasExisting = query.getCriteria().getCriteria().stream().anyMatch(c -> { - if (c instanceof AssociationQuery) { - AssociationQuery aq = (AssociationQuery) c; - return aq.getAssociation().equals(prop); - } - return false; - }); - if (!hasExisting) { - query.add(new AssociationQuery(path, (Association) prop)); + return applyJoinSpecifications(matchContext, query, rootEntity, joinSpecsByAnnotationValue(joinSpecs)); + } + + /** + * + * @param paths Association Paths + * @return set of {@link AssociationJoin} for the supplied association paths. + */ + protected Set joinSpecsByPath(@NonNull Set paths) { + return paths.stream().map(path -> new AssociationJoin(path, null, DEFAULT_JOIN_TYPE)).collect(Collectors.toSet()); + } + + /** + * + * @param joinSpecs {@link Join} Annotation values. + * @return set of {@link AssociationJoin} for the supplied {@link Join} Annotation values. + */ + protected Set joinSpecsByAnnotationValue(@NonNull List> joinSpecs) { + return joinSpecs.stream() + .filter(joinSpec -> joinSpec.stringValue().orElse(null) != null) + .map(joinSpec -> { + String path = joinSpec.stringValue().orElse(null); + Join.Type type = joinSpec.enumValue("type", Join.Type.class).orElse(DEFAULT_JOIN_TYPE); + String alias = joinSpec.stringValue("alias").orElse(null); + return new AssociationJoin(path, alias, type); + }).collect(Collectors.toSet()); + } + + /** + * + * @param matchContext Match Context + * @return set of {@link AssociationJoin} for the {@link Join} annotations. + */ + protected Set joinSpecsForMatchContext(@NonNull MethodMatchContext matchContext) { + final MethodMatchInfo.OperationType operationType = getOperationType(); + if (operationType != MethodMatchInfo.OperationType.QUERY) { + return joinSpecsByAnnotationValue(matchContext.getAnnotationMetadata().getDeclaredAnnotationValuesByType(Join.class)); + } + return joinSpecsByAnnotationValue(matchContext.getAnnotationMetadata().getAnnotationValuesByType(Join.class)); + } + + /** + * Apply the configured join specifications to the given query. + * + * @param matchContext The match context + * @param query The query + * @param rootEntity the root entity + * @param joinSpecs The join specs + * @return True if an error occurred applying the specs + */ + protected boolean applyJoinSpecifications( + @NonNull MethodMatchContext matchContext, + @NonNull QueryModel query, + @Nonnull SourcePersistentEntity rootEntity, + @NonNull Set joinSpecs) { + for (AssociationJoin spec : joinSpecs) { + PersistentProperty prop = rootEntity.getPropertyByPath(spec.getPath()).orElse(null); + if (!(prop instanceof Association)) { + matchContext.fail("Invalid join spec [" + spec.getPath() + "]. Property is not an association!"); + return true; + } else { + boolean hasExisting = query.getCriteria().getCriteria().stream().anyMatch(c -> { + if (c instanceof AssociationQuery) { + AssociationQuery aq = (AssociationQuery) c; + return aq.getAssociation().equals(prop); } - query.join(path, (Association) prop, type, alias); + return false; + }); + if (!hasExisting) { + query.add(new AssociationQuery(spec.getPath(), (Association) prop)); } + query.join(spec.getPath(), (Association) prop, spec.getJoinType(), spec.getAlias()); } } return false; diff --git a/data-processor/src/main/java/io/micronaut/data/processor/visitors/finders/AssociationJoin.java b/data-processor/src/main/java/io/micronaut/data/processor/visitors/finders/AssociationJoin.java new file mode 100644 index 0000000000..7ba53023f8 --- /dev/null +++ b/data-processor/src/main/java/io/micronaut/data/processor/visitors/finders/AssociationJoin.java @@ -0,0 +1,138 @@ +/* + * Copyright 2017-2020 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.data.processor.visitors.finders; + +import edu.umd.cs.findbugs.annotations.NonNull; +import io.micronaut.data.annotation.Join; + +/** + * Defines how a join for a particular association path should be generated. + * @author Sergio del Amo + */ +public class AssociationJoin { + + /** + * The path to join. + */ + @NonNull + private String path; + + /** + * The alias prefix to use for the join. + */ + private String alias; + + /** + * The join Type. + */ + @NonNull + private Join.Type joinType; + + /** + * Constructor. + */ + public AssociationJoin() { + } + + /** + * + * @param path The Path to Join + * @param alias The alias prefix to use for the join + * @param joinType The join Type + */ + public AssociationJoin(@NonNull String path, String alias, @NonNull Join.Type joinType) { + this.path = path; + this.alias = alias; + this.joinType = joinType; + } + + /** + * @return The path to join. + */ + @NonNull + public String getPath() { + return path; + } + + /** + * + * @param path The Path to Join + */ + public void setPath(@NonNull String path) { + this.path = path; + } + + /** + * + * @return The alias prefix to use for the join + */ + public String getAlias() { + return alias; + } + + /** + * + * @param alias The alias prefix to use for the join + */ + public void setAlias(String alias) { + this.alias = alias; + } + + /** + * + * @return The join Type + */ + @NonNull + public Join.Type getJoinType() { + return joinType; + } + + /** + * + * @param joinType The join Type + */ + public void setJoinType(@NonNull Join.Type joinType) { + this.joinType = joinType; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + + AssociationJoin that = (AssociationJoin) o; + + if (!path.equals(that.path)) { + return false; + } + if (alias != null ? !alias.equals(that.alias) : that.alias != null) { + return false; + } + return joinType == that.joinType; + } + + @Override + public int hashCode() { + int result = path.hashCode(); + result = 31 * result + (alias != null ? alias.hashCode() : 0); + result = 31 * result + joinType.hashCode(); + return result; + } +} diff --git a/data-processor/src/main/java/io/micronaut/data/processor/visitors/finders/DynamicFinder.java b/data-processor/src/main/java/io/micronaut/data/processor/visitors/finders/DynamicFinder.java index 7c6e20f58f..3c0e2d431d 100644 --- a/data-processor/src/main/java/io/micronaut/data/processor/visitors/finders/DynamicFinder.java +++ b/data-processor/src/main/java/io/micronaut/data/processor/visitors/finders/DynamicFinder.java @@ -16,13 +16,11 @@ package io.micronaut.data.processor.visitors.finders; import edu.umd.cs.findbugs.annotations.NonNull; -import io.micronaut.core.annotation.AnnotationValue; import io.micronaut.core.annotation.Introspected; import io.micronaut.core.naming.NameUtils; import io.micronaut.core.util.ArrayUtils; import io.micronaut.core.util.CollectionUtils; import io.micronaut.core.util.StringUtils; -import io.micronaut.data.annotation.Join; import io.micronaut.data.annotation.MappedEntity; import io.micronaut.data.annotation.Query; import io.micronaut.data.annotation.TypeRole; @@ -273,10 +271,13 @@ public MethodMatchInfo buildMatchInfo(@NonNull MethodMatchContext matchContext) QueryModel query = QueryModel.from(entity); ClassElement queryResultType = entity.getClassElement(); - List> joinSpecs = joinSpecsAtMatchContext(matchContext); - + Set joinSpecs = joinSpecsForMatchContext(matchContext); + boolean dto = isReturnTypeDto(matchContext.getReturnType(), queryResultType); + if (dto) { + joinSpecs.addAll(joinSpecsByPath(joinPathsForDto(matchContext.getReturnType(), queryResultType))); + } if (CollectionUtils.isNotEmpty(joinSpecs)) { - if (applyJoinSpecs(matchContext, query, entity, joinSpecs)) { + if (applyJoinSpecifications(matchContext, query, entity, joinSpecs)) { return null; } } diff --git a/data-processor/src/test/groovy/io/micronaut/data/processor/visitors/AbstractDataSpec.groovy b/data-processor/src/test/groovy/io/micronaut/data/processor/visitors/AbstractDataSpec.groovy index 4d1a451207..066e07ba02 100644 --- a/data-processor/src/test/groovy/io/micronaut/data/processor/visitors/AbstractDataSpec.groovy +++ b/data-processor/src/test/groovy/io/micronaut/data/processor/visitors/AbstractDataSpec.groovy @@ -18,7 +18,6 @@ package io.micronaut.data.processor.visitors import io.micronaut.annotation.processing.TypeElementVisitorProcessor import io.micronaut.annotation.processing.test.AbstractTypeElementSpec import io.micronaut.annotation.processing.test.JavaParser -import io.micronaut.annotation.processing.visitor.JavaClassElement import io.micronaut.core.naming.NameUtils import io.micronaut.data.processor.model.SourcePersistentEntity import io.micronaut.inject.BeanDefinition @@ -29,8 +28,6 @@ import io.micronaut.inject.writer.BeanDefinitionVisitor import spock.lang.Requires import javax.annotation.processing.SupportedAnnotationTypes -import javax.lang.model.element.TypeElement -import java.util.function.Function @Requires({ javaVersion <= 1.8 }) class AbstractDataSpec extends AbstractTypeElementSpec { diff --git a/data-processor/src/test/groovy/io/micronaut/data/processor/visitors/DtoWithAssociationIdSpec.groovy b/data-processor/src/test/groovy/io/micronaut/data/processor/visitors/DtoWithAssociationIdSpec.groovy new file mode 100644 index 0000000000..4de2d36485 --- /dev/null +++ b/data-processor/src/test/groovy/io/micronaut/data/processor/visitors/DtoWithAssociationIdSpec.groovy @@ -0,0 +1,131 @@ +package io.micronaut.data.processor.visitors + +import io.micronaut.data.annotation.Query +import io.micronaut.inject.BeanDefinition + +class DtoWithAssociationIdSpec extends AbstractDataSpec { + void "test DTO with an association id doesn't fail to compile"() { + when: + BeanDefinition beanDefinition = buildRepository('test.FaceInterface', """ +import io.micronaut.data.model.entities.Person; +import io.micronaut.core.annotation.Introspected; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.Id; +import javax.persistence.OneToOne; +import io.micronaut.data.annotation.Join; + +@Entity +class Nose { + + @GeneratedValue + @Id + private Long id; + + @OneToOne + private Face face; + + public Face getFace() { + return face; + } + + public void setFace(Face face) { + this.face = face; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } +} + +@Entity +class Face { + + @GeneratedValue + @Id + private Long id; + private String name; + + public Face(String name) { + this.name = name; + } + @OneToOne(mappedBy = "face") + private Nose nose; + + public String getName() { + return name; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Nose getNose() { + return nose; + } + + public void setNose(Nose nose) { + this.nose = nose; + } +} + +@Repository +interface FaceInterface extends GenericRepository { + FaceDTO get(Long id); +} + +@Introspected +class FaceDTO { + + private Long id; + private String name; + private Long noseId; + public Long getNoseId() { + return noseId; + } + + public void setNoseId(Long noseId) { + this.noseId = noseId; + } + + public FaceDTO() { + } + + 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; + } +} +""") + then: + beanDefinition != null + + when: + Map signature = ["id": Long] + def method = beanDefinition.getRequiredMethod("get", signature.values() as Class[]) + String query = method.synthesize(Query)value() + + then: + "SELECT face_nose_.id AS noseId,face_.id AS id,face_.name AS name FROM test.Face AS face_ JOIN FETCH face_.nose face_nose_ WHERE (face_.id = :p1)" == query + + } +} diff --git a/data-runtime/src/main/java/io/micronaut/data/runtime/mapper/DTOMapper.java b/data-runtime/src/main/java/io/micronaut/data/runtime/mapper/DTOMapper.java index ae5b4651f6..2868f7da20 100644 --- a/data-runtime/src/main/java/io/micronaut/data/runtime/mapper/DTOMapper.java +++ b/data-runtime/src/main/java/io/micronaut/data/runtime/mapper/DTOMapper.java @@ -24,6 +24,7 @@ import io.micronaut.data.exceptions.DataAccessException; import io.micronaut.data.model.DataType; import io.micronaut.data.model.PersistentEntity; +import io.micronaut.data.model.runtime.RuntimeAssociation; import io.micronaut.data.model.runtime.RuntimePersistentEntity; import io.micronaut.data.model.runtime.RuntimePersistentProperty; import io.micronaut.http.codec.MediaTypeCodec; @@ -41,6 +42,7 @@ public class DTOMapper implements BeanIntrospectionMapper { private final RuntimePersistentEntity persistentEntity; private final ResultReader resultReader; private final @Nullable MediaTypeCodec jsonCodec; + private static final String CAPITALIZED_ID = "Id"; /** * Default constructor. @@ -73,7 +75,16 @@ public DTOMapper( @Nullable @Override public Object read(@NonNull S object, @NonNull String name) throws ConversionErrorException { - RuntimePersistentProperty pp = persistentEntity.getPropertyByName(name); + RuntimePersistentProperty pp = persistentEntity.getPropertyByName(name); + if (pp == null && name.endsWith(CAPITALIZED_ID)) { + pp = persistentEntity.getPropertyByName(name.substring(0, name.indexOf(CAPITALIZED_ID))); + if (pp != null) { + if (pp instanceof RuntimeAssociation) { + RuntimeAssociation runtimeAssociation = (RuntimeAssociation) pp; + pp = runtimeAssociation.getAssociatedEntity().getIdentity(); + } + } + } if (pp == null) { throw new DataAccessException("DTO projection defines a property [" + name + "] that doesn't exist on root entity: " + persistentEntity.getName()); } else { @@ -101,7 +112,7 @@ public Object read(@NonNull S object, @NonNull Argument argument) { * @param property THe property * @return The result */ - public @Nullable Object read(@NonNull S resultSet, @NonNull RuntimePersistentProperty property) { + public @Nullable Object read(@NonNull S resultSet, @NonNull RuntimePersistentProperty property) { String propertyName = property.getPersistedName(); DataType dataType = property.getDataType(); if (dataType == DataType.JSON && jsonCodec != null) { diff --git a/data-tck/src/main/groovy/io/micronaut/data/tck/tests/AbstractRepositorySpec.groovy b/data-tck/src/main/groovy/io/micronaut/data/tck/tests/AbstractRepositorySpec.groovy index 0c25857ca1..77d57061e7 100644 --- a/data-tck/src/main/groovy/io/micronaut/data/tck/tests/AbstractRepositorySpec.groovy +++ b/data-tck/src/main/groovy/io/micronaut/data/tck/tests/AbstractRepositorySpec.groovy @@ -92,6 +92,32 @@ abstract class AbstractRepositorySpec extends Specification { personRepository.deleteAll() } + void "project face to DTO with association id"() { + when: + Face face = faceRepository.save(new Face("Bob")) + Nose nose = noseRepository.save(new Nose(face: face)) + + then: + nose.id + face.id + + and: + faceRepository.queryById(face.id).nose.id == nose.id + + when: + FaceDTO dto = faceRepository.get(face.id) + + then: + dto + dto.id == face.id + dto.name == face.name + dto.noseId == nose.id + + cleanup: + noseRepository.deleteAll() + faceRepository.deleteAll() + } + void "test save one"() { given: savePersons(["Jeff", "James"]) diff --git a/data-tck/src/main/java/io/micronaut/data/tck/entities/FaceDTO.java b/data-tck/src/main/java/io/micronaut/data/tck/entities/FaceDTO.java new file mode 100644 index 0000000000..96a984cd72 --- /dev/null +++ b/data-tck/src/main/java/io/micronaut/data/tck/entities/FaceDTO.java @@ -0,0 +1,53 @@ +/* + * Copyright 2017-2020 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.data.tck.entities; + +import io.micronaut.core.annotation.Introspected; + +@Introspected +public class FaceDTO { + + private Long id; + private String name; + private Long noseId; + + public FaceDTO() { + } + + public Long getNoseId() { + return noseId; + } + + public void setNoseId(Long noseId) { + this.noseId = noseId; + } + + 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; + } +} \ No newline at end of file diff --git a/data-tck/src/main/java/io/micronaut/data/tck/repositories/FaceRepository.java b/data-tck/src/main/java/io/micronaut/data/tck/repositories/FaceRepository.java index d51c1c363c..64789b830a 100644 --- a/data-tck/src/main/java/io/micronaut/data/tck/repositories/FaceRepository.java +++ b/data-tck/src/main/java/io/micronaut/data/tck/repositories/FaceRepository.java @@ -18,9 +18,12 @@ import io.micronaut.data.annotation.Join; import io.micronaut.data.repository.CrudRepository; import io.micronaut.data.tck.entities.Face; +import io.micronaut.data.tck.entities.FaceDTO; public interface FaceRepository extends CrudRepository { @Join("nose") Face queryById(Long id); + + FaceDTO get(Long id); }