diff --git a/elide-core/src/main/java/com/yahoo/elide/core/DataStoreTransaction.java b/elide-core/src/main/java/com/yahoo/elide/core/DataStoreTransaction.java index 8225c74adc..8f171ee41e 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/DataStoreTransaction.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/DataStoreTransaction.java @@ -5,6 +5,7 @@ */ package com.yahoo.elide.core; +import com.yahoo.elide.core.exceptions.InvalidEntityBodyException; import com.yahoo.elide.core.filter.InPredicate; import com.yahoo.elide.core.filter.expression.AndFilterExpression; import com.yahoo.elide.core.filter.expression.FilterExpression; @@ -96,7 +97,8 @@ default T createNewObject(Class entityClass) { try { obj = entityClass.newInstance(); } catch (java.lang.InstantiationException | IllegalAccessException e) { - obj = null; + throw new InvalidEntityBodyException("Cannot create an entity model of type: " + + entityClass.getSimpleName()); } return obj; } diff --git a/elide-core/src/main/java/com/yahoo/elide/core/PersistentResource.java b/elide-core/src/main/java/com/yahoo/elide/core/PersistentResource.java index 6e6ad26c7e..66d34cd4a5 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/PersistentResource.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/PersistentResource.java @@ -388,6 +388,7 @@ public boolean updateAttribute(String fieldName, Object newVal) { transaction.setAttribute(obj, Attribute.builder() .name(fieldName) .type(fieldClass) + .parentType(obj.getClass()) .argument(Argument.builder() .name("_UNUSED_") .value(newVal).build()) diff --git a/elide-core/src/main/java/com/yahoo/elide/core/exceptions/InvalidPredicateException.java b/elide-core/src/main/java/com/yahoo/elide/core/exceptions/InvalidPredicateException.java deleted file mode 100644 index f5402babbc..0000000000 --- a/elide-core/src/main/java/com/yahoo/elide/core/exceptions/InvalidPredicateException.java +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright 2016, Yahoo Inc. - * Licensed under the Apache License, Version 2.0 - * See LICENSE file in project root for terms. - */ -package com.yahoo.elide.core.exceptions; - -/** - * Invalid predicate exception. - */ -@Deprecated -public class InvalidPredicateException extends BadRequestException { - public InvalidPredicateException(String message) { - super(message); - } -} diff --git a/elide-core/src/main/java/com/yahoo/elide/jsonapi/EntityProjectionMaker.java b/elide-core/src/main/java/com/yahoo/elide/jsonapi/EntityProjectionMaker.java index 28bb3de143..f77afb897e 100644 --- a/elide-core/src/main/java/com/yahoo/elide/jsonapi/EntityProjectionMaker.java +++ b/elide-core/src/main/java/com/yahoo/elide/jsonapi/EntityProjectionMaker.java @@ -331,6 +331,7 @@ private Set getSparseAttributes(Class entityClass) { return Sets.intersection(allAttributes, sparseFieldsForEntity).stream() .map(attributeName -> Attribute.builder() + .parentType(entityClass) .name(attributeName) .type(dictionary.getType(entityClass, attributeName)) .build()) diff --git a/elide-core/src/main/java/com/yahoo/elide/request/Attribute.java b/elide-core/src/main/java/com/yahoo/elide/request/Attribute.java index 7a05e71fd4..7af57e2442 100644 --- a/elide-core/src/main/java/com/yahoo/elide/request/Attribute.java +++ b/elide-core/src/main/java/com/yahoo/elide/request/Attribute.java @@ -30,12 +30,27 @@ public class Attribute { @ToString.Exclude private String alias; + @ToString.Exclude + //If null, the parentType is the same as the entity projection to which this attribute belongs. + //If not null, this represents the model type where this attribute can be found. + private Class parentType; + @Singular @ToString.Exclude private Set arguments; + private Attribute(@NonNull Class type, @NonNull String name, String alias, Class parentType, + Set arguments) { + this.type = type; + this.parentType = parentType; + this.name = name; + this.alias = alias == null ? name : alias; + this.arguments = arguments; + } + private Attribute(@NonNull Class type, @NonNull String name, String alias, Set arguments) { this.type = type; + this.parentType = null; this.name = name; this.alias = alias == null ? name : alias; this.arguments = arguments; diff --git a/elide-core/src/main/java/com/yahoo/elide/request/EntityProjection.java b/elide-core/src/main/java/com/yahoo/elide/request/EntityProjection.java index 0b2f1ea4c4..080ecaf91a 100644 --- a/elide-core/src/main/java/com/yahoo/elide/request/EntityProjection.java +++ b/elide-core/src/main/java/com/yahoo/elide/request/EntityProjection.java @@ -6,6 +6,7 @@ package com.yahoo.elide.request; +import com.yahoo.elide.core.exceptions.BadRequestException; import com.yahoo.elide.core.filter.expression.FilterExpression; import com.google.common.collect.Sets; import lombok.AllArgsConstructor; @@ -18,8 +19,6 @@ import java.util.Optional; import java.util.Set; -import javax.ws.rs.BadRequestException; - /** * Represents a client data request against a subgraph of the entity relationship graph. */ @@ -153,6 +152,7 @@ public EntityProjectionBuilder attributes(Set attributes) { public EntityProjectionBuilder relationship(String name, EntityProjection projection) { return relationship(Relationship.builder() .alias(name) + .parentType(projection.type) .name(name) .projection(projection) .build()); @@ -177,6 +177,7 @@ public EntityProjectionBuilder relationship(Relationship relationship) { if (existing != null) { relationships.remove(existing); relationships.add(Relationship.builder() + .parentType(relationship.getParentType()) .name(relationshipName) .alias(relationshipAlias) .projection(existing.getProjection().merge(relationship.getProjection())) @@ -213,6 +214,7 @@ public EntityProjectionBuilder attribute(Attribute attribute) { attributes.remove(existing); attributes.add(Attribute.builder() .type(attribute.getType()) + .parentType(attribute.getParentType()) .name(attributeName) .alias(attributeAlias) .arguments(Sets.union(attribute.getArguments(), existing.getArguments())) @@ -242,6 +244,19 @@ public Attribute getAttributeByAlias(String attributeAlias) { .orElse(null); } + /** + * Get an relationship by alias. + * + * @param relationshipAlias alias to refer to a relationship field + * @return found attribute or null + */ + public Relationship getRelationshipByAlias(String relationshipAlias) { + return relationships.stream() + .filter(relationship -> relationship.getAlias().equals(relationshipAlias)) + .findAny() + .orElse(null); + } + /** * Check whether a field alias is ambiguous. * diff --git a/elide-core/src/main/java/com/yahoo/elide/request/Relationship.java b/elide-core/src/main/java/com/yahoo/elide/request/Relationship.java index 5cbbad0014..973b65c235 100644 --- a/elide-core/src/main/java/com/yahoo/elide/request/Relationship.java +++ b/elide-core/src/main/java/com/yahoo/elide/request/Relationship.java @@ -17,27 +17,40 @@ @Builder public class Relationship { - public RelationshipBuilder copyOf() { - return Relationship.builder() - .alias(alias) - .name(name) - .projection(projection); - } - @NonNull private String name; private String alias; + //If null, the parentType is the same as the entity projection to which this relationship belongs. + //If not null, this represents the model type where this relationship can be found. + private Class parentType; + @NonNull private EntityProjection projection; private Relationship(@NonNull String name, String alias, @NonNull EntityProjection projection) { this.name = name; + this.parentType = null; + this.alias = alias == null ? name : alias; + this.projection = projection; + } + + private Relationship(@NonNull String name, String alias, Class parentType, + @NonNull EntityProjection projection) { + this.name = name; + this.parentType = parentType; this.alias = alias == null ? name : alias; this.projection = projection; } + public RelationshipBuilder copyOf() { + return Relationship.builder() + .alias(alias) + .name(name) + .projection(projection); + } + public Relationship merge(Relationship toMerge) { return Relationship.builder() .name(name) diff --git a/elide-core/src/test/java/com/yahoo/elide/jsonapi/EntityProjectionMakerTest.java b/elide-core/src/test/java/com/yahoo/elide/jsonapi/EntityProjectionMakerTest.java index a76bfbd43a..cd051ee6ef 100644 --- a/elide-core/src/test/java/com/yahoo/elide/jsonapi/EntityProjectionMakerTest.java +++ b/elide-core/src/test/java/com/yahoo/elide/jsonapi/EntityProjectionMakerTest.java @@ -63,11 +63,11 @@ public void testRootCollectionNoQueryParams() { EntityProjection expected = EntityProjection.builder() .type(Book.class) - .attribute(Attribute.builder().name("title").type(String.class).build()) - .attribute(Attribute.builder().name("awards").type(Collection.class).build()) - .attribute(Attribute.builder().name("genre").type(String.class).build()) - .attribute(Attribute.builder().name("language").type(String.class).build()) - .attribute(Attribute.builder().name("publishDate").type(long.class).build()) + .attribute(Attribute.builder().parentType(Book.class).name("title").type(String.class).build()) + .attribute(Attribute.builder().parentType(Book.class).name("awards").type(Collection.class).build()) + .attribute(Attribute.builder().parentType(Book.class).name("genre").type(String.class).build()) + .attribute(Attribute.builder().parentType(Book.class).name("language").type(String.class).build()) + .attribute(Attribute.builder().parentType(Book.class).name("publishDate").type(long.class).build()) .relationship("authors", EntityProjection.builder() .type(Author.class) .build()) @@ -97,8 +97,8 @@ public void testRootCollectionSparseFields() { EntityProjection expected = EntityProjection.builder() .type(Book.class) - .attribute(Attribute.builder().name("title").type(String.class).build()) - .attribute(Attribute.builder().name("publishDate").type(long.class).build()) + .attribute(Attribute.builder().parentType(Book.class).name("title").type(String.class).build()) + .attribute(Attribute.builder().parentType(Book.class).name("publishDate").type(long.class).build()) .relationship("authors", EntityProjection.builder() .type(Author.class) .build()) @@ -121,11 +121,11 @@ public void testRootEntityNoQueryParams() { EntityProjection expected = EntityProjection.builder() .type(Book.class) - .attribute(Attribute.builder().name("title").type(String.class).build()) - .attribute(Attribute.builder().name("awards").type(Collection.class).build()) - .attribute(Attribute.builder().name("genre").type(String.class).build()) - .attribute(Attribute.builder().name("language").type(String.class).build()) - .attribute(Attribute.builder().name("publishDate").type(long.class).build()) + .attribute(Attribute.builder().parentType(Book.class).name("title").type(String.class).build()) + .attribute(Attribute.builder().parentType(Book.class).name("awards").type(Collection.class).build()) + .attribute(Attribute.builder().parentType(Book.class).name("genre").type(String.class).build()) + .attribute(Attribute.builder().parentType(Book.class).name("language").type(String.class).build()) + .attribute(Attribute.builder().parentType(Book.class).name("publishDate").type(long.class).build()) .relationship("authors", EntityProjection.builder() .type(Author.class) .build()) @@ -157,8 +157,8 @@ public void testNestedCollectionNoQueryParams() { .type(Book.class) .relationship("publisher", EntityProjection.builder() .type(Publisher.class) - .attribute(Attribute.builder().name("name").type(String.class).build()) - .attribute(Attribute.builder().name("updateHookInvoked").type(boolean.class).build()) + .attribute(Attribute.builder().parentType(Publisher.class).name("name").type(String.class).build()) + .attribute(Attribute.builder().parentType(Publisher.class).name("updateHookInvoked").type(boolean.class).build()) .relationship("books", EntityProjection.builder() .type(Book.class) .build()) @@ -190,8 +190,8 @@ public void testNestedEntityNoQueryParams() { .type(Book.class) .relationship("publisher", EntityProjection.builder() .type(Publisher.class) - .attribute(Attribute.builder().name("name").type(String.class).build()) - .attribute(Attribute.builder().name("updateHookInvoked").type(boolean.class).build()) + .attribute(Attribute.builder().parentType(Publisher.class).name("name").type(String.class).build()) + .attribute(Attribute.builder().parentType(Publisher.class).name("updateHookInvoked").type(boolean.class).build()) .relationship("books", EntityProjection.builder() .type(Book.class) .build()) @@ -246,10 +246,10 @@ public void testRelationshipWithSingleInclude() { .pagination(PaginationImpl.getDefaultPagination(Publisher.class)) .build()) .relationship("authors", EntityProjection.builder() - .attribute(Attribute.builder().name("name").type(String.class).build()) - .attribute(Attribute.builder().name("type").type(Author.AuthorType.class).build()) - .attribute(Attribute.builder().name("homeAddress").type(Address.class).build()) - .attribute(Attribute.builder().name("awards").type(Collection.class).build()) + .attribute(Attribute.builder().parentType(Author.class).name("name").type(String.class).build()) + .attribute(Attribute.builder().parentType(Author.class).name("type").type(Author.AuthorType.class).build()) + .attribute(Attribute.builder().parentType(Author.class).name("homeAddress").type(Address.class).build()) + .attribute(Attribute.builder().parentType(Author.class).name("awards").type(String.class).build()) .relationship("books", EntityProjection.builder() .type(Book.class) .build()) @@ -277,17 +277,17 @@ public void testRootCollectionWithSingleInclude() { EntityProjection expected = EntityProjection.builder() .type(Book.class) - .attribute(Attribute.builder().name("title").type(String.class).build()) - .attribute(Attribute.builder().name("awards").type(Collection.class).build()) - .attribute(Attribute.builder().name("genre").type(String.class).build()) - .attribute(Attribute.builder().name("language").type(String.class).build()) - .attribute(Attribute.builder().name("publishDate").type(long.class).build()) + .attribute(Attribute.builder().parentType(Book.class).name("title").type(String.class).build()) + .attribute(Attribute.builder().parentType(Book.class).name("awards").type(Collection.class).build()) + .attribute(Attribute.builder().parentType(Book.class).name("genre").type(String.class).build()) + .attribute(Attribute.builder().parentType(Book.class).name("language").type(String.class).build()) + .attribute(Attribute.builder().parentType(Book.class).name("publishDate").type(long.class).build()) .relationship("authors", EntityProjection.builder() .type(Author.class) - .attribute(Attribute.builder().name("name").type(String.class).build()) - .attribute(Attribute.builder().name("type").type(Author.AuthorType.class).build()) - .attribute(Attribute.builder().name("homeAddress").type(Address.class).build()) - .attribute(Attribute.builder().name("awards").type(Collection.class).build()) + .attribute(Attribute.builder().parentType(Author.class).name("name").type(String.class).build()) + .attribute(Attribute.builder().parentType(Author.class).name("type").type(Author.AuthorType.class).build()) + .attribute(Attribute.builder().parentType(Author.class).name("homeAddress").type(Address.class).build()) + .attribute(Attribute.builder().parentType(Author.class).name("awards").type(String.class).build()) .relationship("books", EntityProjection.builder() .type(Book.class) .build()) @@ -318,17 +318,17 @@ public void testRootEntityWithSingleInclude() { EntityProjection expected = EntityProjection.builder() .type(Book.class) - .attribute(Attribute.builder().name("title").type(String.class).build()) - .attribute(Attribute.builder().name("awards").type(Collection.class).build()) - .attribute(Attribute.builder().name("genre").type(String.class).build()) - .attribute(Attribute.builder().name("language").type(String.class).build()) - .attribute(Attribute.builder().name("publishDate").type(long.class).build()) + .attribute(Attribute.builder().parentType(Book.class).name("title").type(String.class).build()) + .attribute(Attribute.builder().parentType(Book.class).name("awards").type(Collection.class).build()) + .attribute(Attribute.builder().parentType(Book.class).name("genre").type(String.class).build()) + .attribute(Attribute.builder().parentType(Book.class).name("language").type(String.class).build()) + .attribute(Attribute.builder().parentType(Book.class).name("publishDate").type(long.class).build()) .relationship("authors", EntityProjection.builder() .type(Author.class) - .attribute(Attribute.builder().name("name").type(String.class).build()) - .attribute(Attribute.builder().name("type").type(Author.AuthorType.class).build()) - .attribute(Attribute.builder().name("homeAddress").type(Address.class).build()) - .attribute(Attribute.builder().name("awards").type(Collection.class).build()) + .attribute(Attribute.builder().parentType(Author.class).name("name").type(String.class).build()) + .attribute(Attribute.builder().parentType(Author.class).name("type").type(Author.AuthorType.class).build()) + .attribute(Attribute.builder().parentType(Author.class).name("homeAddress").type(Address.class).build()) + .attribute(Attribute.builder().parentType(Author.class).name("awards").type(String.class).build()) .relationship("books", EntityProjection.builder() .type(Book.class) .build()) @@ -359,30 +359,30 @@ public void testRootCollectionWithNestedInclude() throws Exception { EntityProjection expected = EntityProjection.builder() .type(Author.class) - .attribute(Attribute.builder().name("homeAddress").type(Address.class).build()) - .attribute(Attribute.builder().name("name").type(String.class).build()) - .attribute(Attribute.builder().name("type").type(Author.AuthorType.class).build()) - .attribute(Attribute.builder().name("awards").type(Collection.class).build()) + .attribute(Attribute.builder().parentType(Author.class).name("homeAddress").type(Address.class).build()) + .attribute(Attribute.builder().parentType(Author.class).name("name").type(String.class).build()) + .attribute(Attribute.builder().parentType(Author.class).name("type").type(Author.AuthorType.class).build()) + .attribute(Attribute.builder().parentType(Author.class).name("awards").type(String.class).build()) .relationship("books", EntityProjection.builder() .type(Book.class) - .attribute(Attribute.builder().name("title").type(String.class).build()) - .attribute(Attribute.builder().name("awards").type(Collection.class).build()) - .attribute(Attribute.builder().name("genre").type(String.class).build()) - .attribute(Attribute.builder().name("language").type(String.class).build()) - .attribute(Attribute.builder().name("publishDate").type(long.class).build()) + .attribute(Attribute.builder().parentType(Book.class).name("title").type(String.class).build()) + .attribute(Attribute.builder().parentType(Book.class).name("awards").type(Collection.class).build()) + .attribute(Attribute.builder().parentType(Book.class).name("genre").type(String.class).build()) + .attribute(Attribute.builder().parentType(Book.class).name("language").type(String.class).build()) + .attribute(Attribute.builder().parentType(Book.class).name("publishDate").type(long.class).build()) .relationship("editor", EntityProjection.builder() .type(Editor.class) - .attribute(Attribute.builder().name("firstName").type(String.class).build()) - .attribute(Attribute.builder().name("lastName").type(String.class).build()) - .attribute(Attribute.builder().name("fullName").type(String.class).build()) + .attribute(Attribute.builder().parentType(Editor.class).name("firstName").type(String.class).build()) + .attribute(Attribute.builder().parentType(Editor.class).name("lastName").type(String.class).build()) + .attribute(Attribute.builder().parentType(Editor.class).name("fullName").type(String.class).build()) .relationship("editor", EntityProjection.builder() .type(Editor.class) .build()) .build()) .relationship("publisher", EntityProjection.builder() .type(Publisher.class) - .attribute(Attribute.builder().name("name").type(String.class).build()) - .attribute(Attribute.builder().name("updateHookInvoked").type(boolean.class).build()) + .attribute(Attribute.builder().parentType(Publisher.class).name("name").type(String.class).build()) + .attribute(Attribute.builder().parentType(Publisher.class).name("updateHookInvoked").type(boolean.class).build()) .relationship("books", EntityProjection.builder() .type(Book.class) .build()) @@ -415,21 +415,21 @@ public void testRootEntityWithNestedInclude() { EntityProjection expected = EntityProjection.builder() .type(Author.class) - .attribute(Attribute.builder().name("name").type(String.class).build()) - .attribute(Attribute.builder().name("type").type(Author.AuthorType.class).build()) - .attribute(Attribute.builder().name("homeAddress").type(Address.class).build()) - .attribute(Attribute.builder().name("awards").type(Collection.class).build()) + .attribute(Attribute.builder().parentType(Author.class).name("name").type(String.class).build()) + .attribute(Attribute.builder().parentType(Author.class).name("type").type(Author.AuthorType.class).build()) + .attribute(Attribute.builder().parentType(Author.class).name("homeAddress").type(Address.class).build()) + .attribute(Attribute.builder().parentType(Author.class).name("awards").type(String.class).build()) .relationship("books", EntityProjection.builder() .type(Book.class) - .attribute(Attribute.builder().name("title").type(String.class).build()) - .attribute(Attribute.builder().name("awards").type(Collection.class).build()) - .attribute(Attribute.builder().name("genre").type(String.class).build()) - .attribute(Attribute.builder().name("language").type(String.class).build()) - .attribute(Attribute.builder().name("publishDate").type(long.class).build()) + .attribute(Attribute.builder().parentType(Book.class).name("title").type(String.class).build()) + .attribute(Attribute.builder().parentType(Book.class).name("awards").type(Collection.class).build()) + .attribute(Attribute.builder().parentType(Book.class).name("genre").type(String.class).build()) + .attribute(Attribute.builder().parentType(Book.class).name("language").type(String.class).build()) + .attribute(Attribute.builder().parentType(Book.class).name("publishDate").type(long.class).build()) .relationship("publisher", EntityProjection.builder() .type(Publisher.class) - .attribute(Attribute.builder().name("name").type(String.class).build()) - .attribute(Attribute.builder().name("updateHookInvoked").type(boolean.class).build()) + .attribute(Attribute.builder().parentType(Publisher.class).name("name").type(String.class).build()) + .attribute(Attribute.builder().parentType(Publisher.class).name("updateHookInvoked").type(boolean.class).build()) .relationship("books", EntityProjection.builder() .type(Book.class) .build()) @@ -439,9 +439,9 @@ public void testRootEntityWithNestedInclude() { .build()) .relationship("editor", EntityProjection.builder() .type(Editor.class) - .attribute(Attribute.builder().name("firstName").type(String.class).build()) - .attribute(Attribute.builder().name("lastName").type(String.class).build()) - .attribute(Attribute.builder().name("fullName").type(String.class).build()) + .attribute(Attribute.builder().parentType(Editor.class).name("firstName").type(String.class).build()) + .attribute(Attribute.builder().parentType(Editor.class).name("lastName").type(String.class).build()) + .attribute(Attribute.builder().parentType(Editor.class).name("fullName").type(String.class).build()) .relationship("editor", EntityProjection.builder() .type(Editor.class) .build()) @@ -473,15 +473,15 @@ public void testNestedEntityWithSingleInclude() { .type(Book.class) .relationship("publisher", EntityProjection.builder() .type(Publisher.class) - .attribute(Attribute.builder().name("name").type(String.class).build()) - .attribute(Attribute.builder().name("updateHookInvoked").type(boolean.class).build()) + .attribute(Attribute.builder().parentType(Publisher.class).name("name").type(String.class).build()) + .attribute(Attribute.builder().parentType(Publisher.class).name("updateHookInvoked").type(boolean.class).build()) .relationship("books", EntityProjection.builder() .type(Book.class) - .attribute(Attribute.builder().name("title").type(String.class).build()) - .attribute(Attribute.builder().name("awards").type(Collection.class).build()) - .attribute(Attribute.builder().name("genre").type(String.class).build()) - .attribute(Attribute.builder().name("language").type(String.class).build()) - .attribute(Attribute.builder().name("publishDate").type(long.class).build()) + .attribute(Attribute.builder().parentType(Book.class).name("title").type(String.class).build()) + .attribute(Attribute.builder().parentType(Book.class).name("awards").type(Collection.class).build()) + .attribute(Attribute.builder().parentType(Book.class).name("genre").type(String.class).build()) + .attribute(Attribute.builder().parentType(Book.class).name("language").type(String.class).build()) + .attribute(Attribute.builder().parentType(Book.class).name("publishDate").type(long.class).build()) .relationship("authors", EntityProjection.builder() .type(Author.class) .build()) @@ -520,14 +520,14 @@ public void testNestedCollectionWithSingleInclude() { .type(Book.class) .relationship("publisher", EntityProjection.builder() .type(Publisher.class) - .attribute(Attribute.builder().name("name").type(String.class).build()) - .attribute(Attribute.builder().name("updateHookInvoked").type(boolean.class).build()) + .attribute(Attribute.builder().parentType(Publisher.class).name("name").type(String.class).build()) + .attribute(Attribute.builder().parentType(Publisher.class).name("updateHookInvoked").type(boolean.class).build()) .relationship("books", EntityProjection.builder() - .attribute(Attribute.builder().name("title").type(String.class).build()) - .attribute(Attribute.builder().name("awards").type(Collection.class).build()) - .attribute(Attribute.builder().name("genre").type(String.class).build()) - .attribute(Attribute.builder().name("language").type(String.class).build()) - .attribute(Attribute.builder().name("publishDate").type(long.class).build()) + .attribute(Attribute.builder().parentType(Book.class).name("title").type(String.class).build()) + .attribute(Attribute.builder().parentType(Book.class).name("awards").type(Collection.class).build()) + .attribute(Attribute.builder().parentType(Book.class).name("genre").type(String.class).build()) + .attribute(Attribute.builder().parentType(Book.class).name("language").type(String.class).build()) + .attribute(Attribute.builder().parentType(Book.class).name("publishDate").type(long.class).build()) .relationship("authors", EntityProjection.builder() .type(Author.class) .build()) @@ -568,20 +568,20 @@ public void testRootEntityWithNestedIncludeAndSparseFields() { EntityProjection expected = EntityProjection.builder() .type(Author.class) - .attribute(Attribute.builder().name("name").type(String.class).build()) - .attribute(Attribute.builder().name("type").type(Author.AuthorType.class).build()) - .attribute(Attribute.builder().name("homeAddress").type(Address.class).build()) - .attribute(Attribute.builder().name("awards").type(Collection.class).build()) + .attribute(Attribute.builder().parentType(Author.class).name("name").type(String.class).build()) + .attribute(Attribute.builder().parentType(Author.class).name("type").type(Author.AuthorType.class).build()) + .attribute(Attribute.builder().parentType(Author.class).name("homeAddress").type(Address.class).build()) + .attribute(Attribute.builder().parentType(Author.class).name("awards").type(String.class).build()) .relationship("books", EntityProjection.builder() .type(Book.class) - .attribute(Attribute.builder().name("title").type(String.class).build()) + .attribute(Attribute.builder().parentType(Book.class).name("title").type(String.class).build()) .relationship("publisher", EntityProjection.builder() .type(Publisher.class) - .attribute(Attribute.builder().name("name").type(String.class).build()) + .attribute(Attribute.builder().parentType(Publisher.class).name("name").type(String.class).build()) .build()) .relationship("editor", EntityProjection.builder() .type(Editor.class) - .attribute(Attribute.builder().name("fullName").type(String.class).build()) + .attribute(Attribute.builder().parentType(Editor.class).name("fullName").type(String.class).build()) .build()) .build()) .build(); @@ -606,11 +606,11 @@ public void testRootCollectionWithGlobalFilter() { EntityProjection expected = EntityProjection.builder() .type(Book.class) - .attribute(Attribute.builder().name("language").type(String.class).build()) - .attribute(Attribute.builder().name("genre").type(String.class).build()) - .attribute(Attribute.builder().name("title").type(String.class).build()) - .attribute(Attribute.builder().name("awards").type(Collection.class).build()) - .attribute(Attribute.builder().name("publishDate").type(long.class).build()) + .attribute(Attribute.builder().parentType(Book.class).name("language").type(String.class).build()) + .attribute(Attribute.builder().parentType(Book.class).name("genre").type(String.class).build()) + .attribute(Attribute.builder().parentType(Book.class).name("title").type(String.class).build()) + .attribute(Attribute.builder().parentType(Book.class).name("awards").type(Collection.class).build()) + .attribute(Attribute.builder().parentType(Book.class).name("publishDate").type(long.class).build()) .filterExpression(expression) .relationship("publisher", EntityProjection.builder() .type(Publisher.class) @@ -648,8 +648,8 @@ public void testNestedCollectionWithTypedFilter() { .type(Book.class) .relationship("publisher", EntityProjection.builder() .type(Publisher.class) - .attribute(Attribute.builder().name("name").type(String.class).build()) - .attribute(Attribute.builder().name("updateHookInvoked").type(boolean.class).build()) + .attribute(Attribute.builder().parentType(Publisher.class).name("name").type(String.class).build()) + .attribute(Attribute.builder().parentType(Publisher.class).name("updateHookInvoked").type(boolean.class).build()) .filterExpression(expression) .relationship("books", EntityProjection.builder() .type(Book.class) @@ -690,10 +690,10 @@ public void testRelationshipsAndIncludeWithFilterAndSort() { .pagination(PaginationImpl.getDefaultPagination(Publisher.class)) .build()) .relationship("authors", EntityProjection.builder() - .attribute(Attribute.builder().name("name").type(String.class).build()) - .attribute(Attribute.builder().name("type").type(Author.AuthorType.class).build()) - .attribute(Attribute.builder().name("homeAddress").type(Address.class).build()) - .attribute(Attribute.builder().name("awards").type(Collection.class).build()) + .attribute(Attribute.builder().parentType(Author.class).name("name").type(String.class).build()) + .attribute(Attribute.builder().parentType(Author.class).name("type").type(Author.AuthorType.class).build()) + .attribute(Attribute.builder().parentType(Author.class).name("homeAddress").type(Address.class).build()) + .attribute(Attribute.builder().parentType(Author.class).name("awards").type(String.class).build()) .filterExpression(new InInsensitivePredicate(new Path(Author.class, dictionary, "name"), "Foo")) .relationship("books", EntityProjection.builder() .type(Book.class) @@ -725,11 +725,11 @@ public void testRootCollectionWithTypedFilter() { EntityProjection expected = EntityProjection.builder() .type(Book.class) - .attribute(Attribute.builder().name("title").type(String.class).build()) - .attribute(Attribute.builder().name("awards").type(Collection.class).build()) - .attribute(Attribute.builder().name("genre").type(String.class).build()) - .attribute(Attribute.builder().name("language").type(String.class).build()) - .attribute(Attribute.builder().name("publishDate").type(long.class).build()) + .attribute(Attribute.builder().parentType(Book.class).name("title").type(String.class).build()) + .attribute(Attribute.builder().parentType(Book.class).name("awards").type(Collection.class).build()) + .attribute(Attribute.builder().parentType(Book.class).name("genre").type(String.class).build()) + .attribute(Attribute.builder().parentType(Book.class).name("language").type(String.class).build()) + .attribute(Attribute.builder().parentType(Book.class).name("publishDate").type(long.class).build()) .filterExpression(expression) .relationship("authors", EntityProjection.builder() .type(Author.class) diff --git a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/QueryEngine.java b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/QueryEngine.java index 6c57bce21a..bae0caa506 100644 --- a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/QueryEngine.java +++ b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/QueryEngine.java @@ -8,7 +8,7 @@ import com.yahoo.elide.core.DataStore; import com.yahoo.elide.core.DataStoreTransaction; import com.yahoo.elide.core.EntityDictionary; -import com.yahoo.elide.core.exceptions.InvalidPredicateException; +import com.yahoo.elide.core.exceptions.BadRequestException; import com.yahoo.elide.datastores.aggregation.metadata.MetaDataStore; import com.yahoo.elide.datastores.aggregation.metadata.models.Dimension; import com.yahoo.elide.datastores.aggregation.metadata.models.Metric; @@ -149,7 +149,7 @@ private void populateMetaData(MetaDataStore metaDataStore) { .forEach(model -> { if (!metadataDictionary.isJPAEntity(model) && !metadataDictionary.getRelationships(model).isEmpty()) { - throw new InvalidPredicateException( + throw new BadRequestException( "Non-JPA entities " + model.getSimpleName() + " is not allowed to have relationship."); } }); diff --git a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/SQLQueryEngine.java b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/SQLQueryEngine.java index 86c37d5e37..0c5b45e6e7 100644 --- a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/SQLQueryEngine.java +++ b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/SQLQueryEngine.java @@ -9,7 +9,7 @@ import com.yahoo.elide.core.EntityDictionary; import com.yahoo.elide.core.TimedFunction; -import com.yahoo.elide.core.exceptions.InvalidPredicateException; +import com.yahoo.elide.core.exceptions.BadRequestException; import com.yahoo.elide.core.filter.FilterPredicate; import com.yahoo.elide.core.filter.expression.PredicateExtractionVisitor; import com.yahoo.elide.datastores.aggregation.QueryEngine; @@ -190,7 +190,7 @@ private SQLQuery toSQL(Query query) { SQLQueryTemplate queryTemplate = query.getMetrics().stream() .map(metricProjection -> { if (!(metricProjection.getColumn().getMetricFunction() instanceof SQLMetricFunction)) { - throw new InvalidPredicateException( + throw new BadRequestException( "Non-SQL metric function on " + metricProjection.getAlias()); } diff --git a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/query/SQLQueryConstructor.java b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/query/SQLQueryConstructor.java index 69e932dc3d..2ec776677e 100644 --- a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/query/SQLQueryConstructor.java +++ b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/query/SQLQueryConstructor.java @@ -12,7 +12,7 @@ import com.yahoo.elide.core.EntityDictionary; import com.yahoo.elide.core.Path; -import com.yahoo.elide.core.exceptions.InvalidPredicateException; +import com.yahoo.elide.core.exceptions.BadRequestException; import com.yahoo.elide.core.filter.FilterPredicate; import com.yahoo.elide.core.filter.FilterTranslator; import com.yahoo.elide.core.filter.expression.FilterExpression; @@ -143,7 +143,7 @@ private String constructHavingClauseWithReference(FilterPredicate predicate, SQL Table table = template.getTable(); if (!lastClass.equals(dictionary.getEntityClass(table.getName(), table.getVersion()))) { - throw new InvalidPredicateException("The having clause can only reference fact table aggregations."); + throw new BadRequestException("The having clause can only reference fact table aggregations."); } SQLMetricProjection metric = template.getMetrics().stream() diff --git a/elide-datastore/elide-datastore-hibernate3/src/test/java/com/yahoo/elide/datastores/hibernate3/HibernateDataStoreHarness.java b/elide-datastore/elide-datastore-hibernate3/src/test/java/com/yahoo/elide/datastores/hibernate3/HibernateDataStoreHarness.java index a35a05bc9c..8f6d4235a9 100644 --- a/elide-datastore/elide-datastore-hibernate3/src/test/java/com/yahoo/elide/datastores/hibernate3/HibernateDataStoreHarness.java +++ b/elide-datastore/elide-datastore-hibernate3/src/test/java/com/yahoo/elide/datastores/hibernate3/HibernateDataStoreHarness.java @@ -11,6 +11,7 @@ import com.yahoo.elide.utils.ClassScanner; import example.Parent; import example.models.generics.Manager; +import example.models.inheritance.Droid; import example.models.triggers.Invoice; import example.models.versioned.BookV2; import org.hibernate.MappingException; @@ -38,6 +39,8 @@ public HibernateDataStoreHarness() { .forEach(configuration::addAnnotatedClass); ClassScanner.getAnnotatedClasses(Manager.class.getPackage(), Entity.class) .forEach(configuration::addAnnotatedClass); + ClassScanner.getAnnotatedClasses(Droid.class.getPackage(), Entity.class) + .forEach(configuration::addAnnotatedClass); ClassScanner.getAnnotatedClasses(Invoice.class.getPackage(), Entity.class) .forEach(configuration::addAnnotatedClass); ClassScanner.getAnnotatedClasses(BookV2.class.getPackage(), Entity.class) diff --git a/elide-datastore/elide-datastore-hibernate5/src/test/java/com/yahoo/elide/datastores/hibernate5/HibernateDataStoreHarness.java b/elide-datastore/elide-datastore-hibernate5/src/test/java/com/yahoo/elide/datastores/hibernate5/HibernateDataStoreHarness.java index ac63a6d90d..ade1379442 100644 --- a/elide-datastore/elide-datastore-hibernate5/src/test/java/com/yahoo/elide/datastores/hibernate5/HibernateDataStoreHarness.java +++ b/elide-datastore/elide-datastore-hibernate5/src/test/java/com/yahoo/elide/datastores/hibernate5/HibernateDataStoreHarness.java @@ -11,6 +11,7 @@ import com.yahoo.elide.utils.ClassScanner; import example.Parent; import example.models.generics.Manager; +import example.models.inheritance.Droid; import example.models.triggers.Invoice; import example.models.versioned.BookV2; import org.hibernate.MappingException; @@ -50,6 +51,8 @@ public HibernateDataStoreHarness() { .forEach(metadataSources::addAnnotatedClass); ClassScanner.getAnnotatedClasses(Manager.class.getPackage(), Entity.class) .forEach(metadataSources::addAnnotatedClass); + ClassScanner.getAnnotatedClasses(Droid.class.getPackage(), Entity.class) + .forEach(metadataSources::addAnnotatedClass); ClassScanner.getAnnotatedClasses(Invoice.class.getPackage(), Entity.class) .forEach(metadataSources::addAnnotatedClass); ClassScanner.getAnnotatedClasses(BookV2.class.getPackage(), Entity.class) diff --git a/elide-datastore/elide-datastore-hibernate5/src/test/java/com/yahoo/elide/datastores/hibernate5/HibernateEntityManagerDataStoreHarness.java b/elide-datastore/elide-datastore-hibernate5/src/test/java/com/yahoo/elide/datastores/hibernate5/HibernateEntityManagerDataStoreHarness.java index 5f07bd0245..a60c8c0331 100644 --- a/elide-datastore/elide-datastore-hibernate5/src/test/java/com/yahoo/elide/datastores/hibernate5/HibernateEntityManagerDataStoreHarness.java +++ b/elide-datastore/elide-datastore-hibernate5/src/test/java/com/yahoo/elide/datastores/hibernate5/HibernateEntityManagerDataStoreHarness.java @@ -13,6 +13,7 @@ import example.Parent; import example.TestCheckMappings; import example.models.generics.Manager; +import example.models.inheritance.Droid; import example.models.triggers.Invoice; import example.models.versioned.BookV2; import org.hibernate.MappingException; @@ -56,6 +57,7 @@ public HibernateEntityManagerDataStoreHarness() { try { bindClasses.addAll(ClassScanner.getAnnotatedClasses(Parent.class.getPackage(), Entity.class)); bindClasses.addAll(ClassScanner.getAnnotatedClasses(Manager.class.getPackage(), Entity.class)); + bindClasses.addAll(ClassScanner.getAnnotatedClasses(Droid.class.getPackage(), Entity.class)); bindClasses.addAll(ClassScanner.getAnnotatedClasses(Invoice.class.getPackage(), Entity.class)); bindClasses.addAll(ClassScanner.getAnnotatedClasses(BookV2.class.getPackage(), Entity.class)); bindClasses.addAll(ClassScanner.getAnnotatedClasses(AsyncQuery.class.getPackage(), Entity.class)); diff --git a/elide-datastore/elide-datastore-jpa/src/test/java/com/yahoo/elide/datastores/jpa/JpaDataStoreHarness.java b/elide-datastore/elide-datastore-jpa/src/test/java/com/yahoo/elide/datastores/jpa/JpaDataStoreHarness.java index 05ffdf6289..2e2d7466e3 100644 --- a/elide-datastore/elide-datastore-jpa/src/test/java/com/yahoo/elide/datastores/jpa/JpaDataStoreHarness.java +++ b/elide-datastore/elide-datastore-jpa/src/test/java/com/yahoo/elide/datastores/jpa/JpaDataStoreHarness.java @@ -13,6 +13,7 @@ import com.yahoo.elide.utils.ClassScanner; import example.Parent; import example.models.generics.Manager; +import example.models.inheritance.Droid; import example.models.triggers.Invoice; import example.models.versioned.BookV2; import org.hibernate.MappingException; @@ -50,6 +51,7 @@ public JpaDataStoreHarness() { try { bindClasses.addAll(ClassScanner.getAnnotatedClasses(Parent.class.getPackage(), Entity.class)); bindClasses.addAll(ClassScanner.getAnnotatedClasses(Manager.class.getPackage(), Entity.class)); + bindClasses.addAll(ClassScanner.getAnnotatedClasses(Droid.class.getPackage(), Entity.class)); bindClasses.addAll(ClassScanner.getAnnotatedClasses(Invoice.class.getPackage(), Entity.class)); bindClasses.addAll(ClassScanner.getAnnotatedClasses(BookV2.class.getPackage(), Entity.class)); bindClasses.addAll(ClassScanner.getAnnotatedClasses(AsyncQuery.class.getPackage(), Entity.class)); diff --git a/elide-example-models/src/main/java/example/models/inheritance/Character.java b/elide-example-models/src/main/java/example/models/inheritance/Character.java new file mode 100644 index 0000000000..df5da760e5 --- /dev/null +++ b/elide-example-models/src/main/java/example/models/inheritance/Character.java @@ -0,0 +1,26 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package example.models.inheritance; + +import com.yahoo.elide.annotation.Include; +import lombok.Data; + +import javax.persistence.DiscriminatorColumn; +import javax.persistence.Entity; +import javax.persistence.Id; +import javax.persistence.Inheritance; +import javax.persistence.InheritanceType; + +@Include(rootLevel = true) +@Inheritance(strategy = InheritanceType.SINGLE_TABLE) +@DiscriminatorColumn(name = "CharacterType") +@Entity +@Data +public abstract class Character { + @Id + private String name; +} diff --git a/elide-example-models/src/main/java/example/models/inheritance/Droid.java b/elide-example-models/src/main/java/example/models/inheritance/Droid.java new file mode 100644 index 0000000000..b202dfd1c2 --- /dev/null +++ b/elide-example-models/src/main/java/example/models/inheritance/Droid.java @@ -0,0 +1,21 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package example.models.inheritance; + +import com.yahoo.elide.annotation.Include; +import lombok.Data; + +import javax.persistence.DiscriminatorValue; +import javax.persistence.Entity; + +@Include(rootLevel = true) +@DiscriminatorValue("Droid") +@Entity +@Data +public class Droid extends Character { + private String primaryFunction; +} diff --git a/elide-example-models/src/main/java/example/models/inheritance/Hero.java b/elide-example-models/src/main/java/example/models/inheritance/Hero.java new file mode 100644 index 0000000000..6c8398e8fa --- /dev/null +++ b/elide-example-models/src/main/java/example/models/inheritance/Hero.java @@ -0,0 +1,21 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package example.models.inheritance; + +import com.yahoo.elide.annotation.Include; +import lombok.Data; + +import javax.persistence.DiscriminatorValue; +import javax.persistence.Entity; + +@Include(rootLevel = true) +@DiscriminatorValue("Hero") +@Entity +@Data +public class Hero extends Character { + private boolean forceSensitive; +} diff --git a/elide-graphql/pom.xml b/elide-graphql/pom.xml index abea7a016d..c0378c876c 100644 --- a/elide-graphql/pom.xml +++ b/elide-graphql/pom.xml @@ -50,6 +50,13 @@ com.yahoo.elide elide-test-helpers 5.0.0-pr9-SNAPSHOT + test + + + com.yahoo.elide + elide-example-models + 5.0.0-pr9-SNAPSHOT + test com.fasterxml.jackson.core diff --git a/elide-graphql/src/main/java/com/yahoo/elide/graphql/GraphQLNameUtils.java b/elide-graphql/src/main/java/com/yahoo/elide/graphql/GraphQLNameUtils.java index 7c60d5c563..09e82abae2 100644 --- a/elide-graphql/src/main/java/com/yahoo/elide/graphql/GraphQLNameUtils.java +++ b/elide-graphql/src/main/java/com/yahoo/elide/graphql/GraphQLNameUtils.java @@ -10,6 +10,9 @@ import org.apache.commons.lang3.StringUtils; +import java.util.HashMap; +import java.util.Map; + public class GraphQLNameUtils { private static final String MAP_SUFFIX = "Map"; private static final String INPUT_SUFFIX = "Input"; @@ -17,9 +20,18 @@ public class GraphQLNameUtils { private static final String EDGE_SUFFIX = "Edge"; private final EntityDictionary dictionary; + private Map apiToDictionaryNameMapping = new HashMap<>(); public GraphQLNameUtils(EntityDictionary dictionary) { this.dictionary = dictionary; + + dictionary.getBindings().stream().forEach(binding -> { + apiToDictionaryNameMapping.put(toOutputTypeName(binding.entityClass), binding.entityName); + }); + } + + public String toBoundName(String outputTypeName) { + return apiToDictionaryNameMapping.get(outputTypeName); } public String toOutputTypeName(Class clazz) { diff --git a/elide-graphql/src/main/java/com/yahoo/elide/graphql/ModelBuilder.java b/elide-graphql/src/main/java/com/yahoo/elide/graphql/ModelBuilder.java index f11e5d3aab..f3184a4c68 100644 --- a/elide-graphql/src/main/java/com/yahoo/elide/graphql/ModelBuilder.java +++ b/elide-graphql/src/main/java/com/yahoo/elide/graphql/ModelBuilder.java @@ -9,30 +9,37 @@ import static graphql.schema.GraphQLArgument.newArgument; import static graphql.schema.GraphQLFieldDefinition.newFieldDefinition; import static graphql.schema.GraphQLInputObjectField.newInputObjectField; +import static graphql.schema.GraphQLInterfaceType.newInterface; import static graphql.schema.GraphQLObjectType.newObject; import com.yahoo.elide.core.EntityDictionary; import com.yahoo.elide.core.RelationshipType; +import com.yahoo.elide.graphql.containers.NodeContainer; import org.apache.commons.collections4.CollectionUtils; import graphql.Scalars; +import graphql.TypeResolutionEnvironment; import graphql.schema.DataFetcher; import graphql.schema.GraphQLArgument; import graphql.schema.GraphQLInputObjectType; import graphql.schema.GraphQLInputType; +import graphql.schema.GraphQLInterfaceType; import graphql.schema.GraphQLList; import graphql.schema.GraphQLObjectType; import graphql.schema.GraphQLOutputType; import graphql.schema.GraphQLSchema; import graphql.schema.GraphQLType; import graphql.schema.GraphQLTypeReference; +import graphql.schema.TypeResolver; import lombok.extern.slf4j.Slf4j; +import java.lang.reflect.Modifier; import java.util.HashMap; import java.util.HashSet; import java.util.Map; import java.util.Set; +import java.util.function.Function; import java.util.stream.Collectors; /** @@ -66,10 +73,11 @@ public class ModelBuilder { private Map, MutableGraphQLInputObjectType> inputObjectRegistry; private Map, GraphQLObjectType> queryObjectRegistry; + private Map, GraphQLInterfaceType> queryInterfaceRegistry; private Map, GraphQLObjectType> connectionObjectRegistry; private Set> excludedEntities; - private HashMap convertedInputs = new HashMap<>(); + private Map convertedInputs = new HashMap<>(); /** * Class constructor, constructs the custom arguments to handle mutations @@ -136,6 +144,7 @@ public ModelBuilder(EntityDictionary dictionary, DataFetcher dataFetcher, String inputObjectRegistry = new HashMap<>(); queryObjectRegistry = new HashMap<>(); + queryInterfaceRegistry = new HashMap<>(); connectionObjectRegistry = new HashMap<>(); excludedEntities = new HashSet<>(); } @@ -213,12 +222,17 @@ private GraphQLObjectType buildConnectionObject(Class entityClass) { String entityName = nameUtils.toConnectionName(entityClass); + Function, GraphQLOutputType> outputTypeProvider = this::buildQueryObject; + if (Modifier.isAbstract(entityClass.getModifiers())) { + outputTypeProvider = this::buildInterfaceQueryObject; + } + GraphQLObjectType connectionObject = newObject() .name(entityName) .field(newFieldDefinition() .name("edges") .dataFetcher(dataFetcher) - .type(buildEdgesObject(entityClass, buildQueryObject(entityClass)))) + .type(buildEdgesObject(entityClass, outputTypeProvider.apply(entityClass)))) .field(newFieldDefinition() .name("pageInfo") .dataFetcher(dataFetcher) @@ -242,8 +256,18 @@ private GraphQLObjectType buildQueryObject(Class entityClass) { log.debug("Building query object for {}", entityClass.getName()); + String apiTypeName = nameUtils.toNodeName(entityClass); GraphQLObjectType.Builder builder = newObject() - .name(nameUtils.toNodeName(entityClass)); + .name(apiTypeName); + + //Add interfaces the type conforms to. + dictionary.getSuperClassEntities(entityClass).stream() + .filter((superClass) -> Modifier.isAbstract(superClass.getModifiers())) + .forEach((superClass) -> { + GraphQLInterfaceType interfaceType = queryInterfaceRegistry.computeIfAbsent(superClass, + this::buildInterfaceQueryObject); + builder.withInterface(interfaceType); + }); String id = dictionary.getIdFieldName(entityClass); /* our id types are DeferredId objects (not Scalars.GraphQLID) */ @@ -317,6 +341,97 @@ private GraphQLObjectType buildQueryObject(Class entityClass) { return queryObject; } + private GraphQLInterfaceType buildInterfaceQueryObject(Class entityClass) { + if (queryInterfaceRegistry.containsKey(entityClass)) { + return queryInterfaceRegistry.get(entityClass); + } + + log.debug("Building query object for {}", entityClass.getName()); + + GraphQLInterfaceType.Builder builder = newInterface() + .name(nameUtils.toNodeName(entityClass)); + + String id = dictionary.getIdFieldName(entityClass); + /* our id types are DeferredId objects (not Scalars.GraphQLID) */ + builder.field(newFieldDefinition() + .name(id) + .dataFetcher(dataFetcher) + .type(GraphQLScalars.GRAPHQL_DEFERRED_ID)); + + for (String attribute : dictionary.getAttributes(entityClass)) { + Class attributeClass = dictionary.getType(entityClass, attribute); + if (excludedEntities.contains(attributeClass)) { + continue; + } + + log.debug("Building query attribute {} {} with arguments {} for entity {}", + attribute, + attributeClass.getName(), + dictionary.getAttributeArguments(attributeClass, attribute).toString(), + entityClass.getName()); + + GraphQLType attributeType = + generator.attributeToQueryObject(entityClass, attributeClass, attribute, dataFetcher); + + if (attributeType == null) { + continue; + } + + builder.field(newFieldDefinition() + .name(attribute) + .argument(generator.attributeArgumentToQueryObject(entityClass, attribute, dataFetcher)) + .dataFetcher(dataFetcher) + .type((GraphQLOutputType) attributeType) + ); + } + + for (String relationship : dictionary.getElideBoundRelationships(entityClass)) { + Class relationshipClass = dictionary.getParameterizedType(entityClass, relationship); + if (excludedEntities.contains(relationshipClass)) { + continue; + } + + String relationshipEntityName = nameUtils.toConnectionName(relationshipClass); + RelationshipType type = dictionary.getRelationshipType(entityClass, relationship); + + if (type.isToOne()) { + builder.field(newFieldDefinition() + .name(relationship) + .dataFetcher(dataFetcher) + .argument(relationshipOpArg) + .argument(buildInputObjectArgument(relationshipClass, false)) + .type(new GraphQLTypeReference(relationshipEntityName)) + ); + } else { + builder.field(newFieldDefinition() + .name(relationship) + .dataFetcher(dataFetcher) + .argument(relationshipOpArg) + .argument(filterArgument) + .argument(sortArgument) + .argument(pageOffsetArgument) + .argument(pageFirstArgument) + .argument(idArgument) + .argument(buildInputObjectArgument(relationshipClass, true)) + .type(new GraphQLTypeReference(relationshipEntityName)) + ); + } + } + + builder.typeResolver(new TypeResolver() { + @Override + public GraphQLObjectType getType(TypeResolutionEnvironment env) { + NodeContainer node = (NodeContainer) env.getObject(); + String apiName = nameUtils.toNodeName(node.getPersistentResource().getResourceClass()); + return env.getSchema().getObjectType(apiName); + } + }); + + GraphQLInterfaceType queryObject = builder.build(); + queryInterfaceRegistry.put(entityClass, queryObject); + return queryObject; + } + private GraphQLList buildEdgesObject(Class relationClass, GraphQLOutputType entityType) { return new GraphQLList(newObject() .name(nameUtils.toEdgesName(relationClass)) diff --git a/elide-graphql/src/main/java/com/yahoo/elide/graphql/containers/EdgesContainer.java b/elide-graphql/src/main/java/com/yahoo/elide/graphql/containers/EdgesContainer.java index eef4afd470..2855262a40 100644 --- a/elide-graphql/src/main/java/com/yahoo/elide/graphql/containers/EdgesContainer.java +++ b/elide-graphql/src/main/java/com/yahoo/elide/graphql/containers/EdgesContainer.java @@ -31,6 +31,7 @@ public Object processFetch(Environment context, PersistentResourceFetcher fetche return new NodeContainer(context.parentResource); } - throw new BadRequestException("Invalid request. Looking for field: " + fieldName + " in an edges object."); + throw new BadRequestException("Invalid request. Looking for field: " + + fieldName + " in an edges object."); } } diff --git a/elide-graphql/src/main/java/com/yahoo/elide/graphql/containers/MapEntryContainer.java b/elide-graphql/src/main/java/com/yahoo/elide/graphql/containers/MapEntryContainer.java index 8576c96415..f055e0d8ba 100644 --- a/elide-graphql/src/main/java/com/yahoo/elide/graphql/containers/MapEntryContainer.java +++ b/elide-graphql/src/main/java/com/yahoo/elide/graphql/containers/MapEntryContainer.java @@ -39,6 +39,7 @@ public Object processFetch(Environment context, PersistentResourceFetcher fetche return entry.getValue(); } - throw new BadRequestException("Invalid field: '" + fieldName + "'. Maps only contain fields 'key' and 'value'"); + throw new BadRequestException("Invalid field: '" + fieldName + + "'. Maps only contain fields 'key' and 'value'"); } } diff --git a/elide-graphql/src/main/java/com/yahoo/elide/graphql/parser/FragmentResolver.java b/elide-graphql/src/main/java/com/yahoo/elide/graphql/parser/FragmentResolver.java index cb00878555..a34d556486 100644 --- a/elide-graphql/src/main/java/com/yahoo/elide/graphql/parser/FragmentResolver.java +++ b/elide-graphql/src/main/java/com/yahoo/elide/graphql/parser/FragmentResolver.java @@ -6,6 +6,7 @@ package com.yahoo.elide.graphql.parser; +import com.yahoo.elide.core.exceptions.BadRequestException; import com.yahoo.elide.core.exceptions.InvalidEntityBodyException; import graphql.language.Document; @@ -23,7 +24,6 @@ import java.util.Set; import java.util.function.Function; import java.util.stream.Collectors; -import javax.ws.rs.BadRequestException; /** * Class that fetch {@link FragmentDefinition}s from graphQL {@link Document} and store them for future reference. diff --git a/elide-graphql/src/main/java/com/yahoo/elide/graphql/parser/GraphQLEntityProjectionMaker.java b/elide-graphql/src/main/java/com/yahoo/elide/graphql/parser/GraphQLEntityProjectionMaker.java index c0fe08b463..b48261a958 100644 --- a/elide-graphql/src/main/java/com/yahoo/elide/graphql/parser/GraphQLEntityProjectionMaker.java +++ b/elide-graphql/src/main/java/com/yahoo/elide/graphql/parser/GraphQLEntityProjectionMaker.java @@ -18,6 +18,7 @@ import com.yahoo.elide.ElideSettings; import com.yahoo.elide.core.EntityDictionary; import com.yahoo.elide.core.RelationshipType; +import com.yahoo.elide.core.exceptions.BadRequestException; import com.yahoo.elide.core.exceptions.InvalidEntityBodyException; import com.yahoo.elide.core.exceptions.InvalidValueException; import com.yahoo.elide.core.filter.dialect.MultipleFilterDialect; @@ -40,6 +41,7 @@ import graphql.language.Field; import graphql.language.FragmentDefinition; import graphql.language.FragmentSpread; +import graphql.language.InlineFragment; import graphql.language.OperationDefinition; import graphql.language.Selection; import graphql.language.SelectionSet; @@ -54,7 +56,6 @@ import java.util.Optional; import java.util.stream.Collectors; -import javax.ws.rs.BadRequestException; import javax.ws.rs.core.MultivaluedHashMap; /** @@ -221,6 +222,8 @@ private void addSelection(Selection fieldSelection, final EntityProjectionBuilde } else { addField((Field) fieldSelection, projectionBuilder); } + } else if (fieldSelection instanceof InlineFragment) { + addInlineFragment((InlineFragment) fieldSelection, projectionBuilder); } else { throw new InvalidEntityBodyException( String.format("Unsupported selection type {%s}.", fieldSelection.getClass())); @@ -246,6 +249,42 @@ private void addFragment(FragmentSpread fragment, EntityProjectionBuilder projec } } + /** + * Adds a new graphQL {@link InlineFragment} into an {@link EntityProjection}. Inline fragments + * are only supported inside nodes where an interface has been requested. + * + * @param fragment graphQL fragment. + * @param projectionBuilder projection that is being built + */ + private void addInlineFragment(InlineFragment fragment, EntityProjectionBuilder projectionBuilder) { + //Lookup the type requested in the fragment. + String apiType = fragment.getTypeCondition().getName(); + Class entityClass = entityDictionary.getEntityClass(nameUtils.toBoundName(apiType), apiVersion); + + fragment.getSelectionSet().getSelections().forEach( + selection -> { + Field field = (Field) selection; + + //Build another projection based on the type requested. + EntityProjectionBuilder subProjection = EntityProjection.builder() + .type(entityClass); + addSelection(selection, subProjection); + + String fieldName = field.getName(); + String alias = field.getAlias(); + alias = (alias == null || alias.isEmpty()) ? fieldName : alias; + + //Copy the projection attributes and relationships into the original projection. + if (entityDictionary.getRelationshipType(entityClass, fieldName) != RelationshipType.NONE) { + Relationship relationship = subProjection.getRelationshipByAlias(alias); + projectionBuilder.relationship(relationship); + } else { + Attribute attribute = subProjection.getAttributeByAlias(alias); + projectionBuilder.attribute(attribute); + } + }); + } + /** * Add a new graphQL {@link Field} into an {@link EntityProjection} * @@ -317,6 +356,7 @@ private void addAttributeField(Field attributeField, EntityProjectionBuilder pro Class attributeType = entityDictionary.getType(parentType, attributeName); if (attributeType != null) { Attribute attribute = Attribute.builder() + .parentType(projectionBuilder.getType()) .type(attributeType) .name(attributeName) .alias(attributeAlias) @@ -565,6 +605,7 @@ private void addAttributeArgument(Argument argument, EntityProjectionBuilder pro if (existingAttribute != null) { // add a new argument to the existing attribute Attribute toAdd = Attribute.builder() + .parentType(existingAttribute.getParentType()) .type(existingAttribute.getType()) .name(existingAttribute.getName()) .alias(existingAttribute.getAlias()) @@ -584,6 +625,7 @@ private void addAttributeArgument(Argument argument, EntityProjectionBuilder pro // create a new attribute if this attribute doesn't exist in the projection Attribute toAdd = Attribute.builder() + .parentType(projectionBuilder.getType()) .type(attributeType) .name(argumentName) .alias(argumentName) diff --git a/elide-graphql/src/main/java/com/yahoo/elide/graphql/parser/VariableResolver.java b/elide-graphql/src/main/java/com/yahoo/elide/graphql/parser/VariableResolver.java index a2a6e6dc85..58a26ece44 100644 --- a/elide-graphql/src/main/java/com/yahoo/elide/graphql/parser/VariableResolver.java +++ b/elide-graphql/src/main/java/com/yahoo/elide/graphql/parser/VariableResolver.java @@ -6,6 +6,7 @@ package com.yahoo.elide.graphql.parser; +import com.yahoo.elide.core.exceptions.BadRequestException; import graphql.language.ArrayValue; import graphql.language.BooleanValue; import graphql.language.EnumValue; @@ -26,8 +27,6 @@ import java.util.Map; import java.util.stream.Collectors; -import javax.ws.rs.BadRequestException; - /** * Class that contains variables provided in graphql request and can resolve variables based on * {@link graphql.language.OperationDefinition} scope. diff --git a/elide-graphql/src/test/java/com/yahoo/elide/graphql/GraphQLEntityProjectionMakerTest.java b/elide-graphql/src/test/java/com/yahoo/elide/graphql/GraphQLEntityProjectionMakerTest.java new file mode 100644 index 0000000000..910d8ec136 --- /dev/null +++ b/elide-graphql/src/test/java/com/yahoo/elide/graphql/GraphQLEntityProjectionMakerTest.java @@ -0,0 +1,64 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.graphql; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.yahoo.elide.ElideSettings; +import com.yahoo.elide.ElideSettingsBuilder; +import com.yahoo.elide.core.filter.dialect.RSQLFilterDialect; +import com.yahoo.elide.graphql.parser.GraphQLEntityProjectionMaker; +import com.yahoo.elide.graphql.parser.GraphQLProjectionInfo; +import com.yahoo.elide.request.Attribute; +import com.yahoo.elide.request.EntityProjection; +import example.models.inheritance.Character; +import example.models.inheritance.Droid; +import org.junit.jupiter.api.Test; + +import java.util.TimeZone; + +public class GraphQLEntityProjectionMakerTest extends GraphQLTest { + ElideSettings settings; + + public GraphQLEntityProjectionMakerTest() { + RSQLFilterDialect filterDialect = new RSQLFilterDialect(dictionary); + + settings = new ElideSettingsBuilder(null) + .withEntityDictionary(dictionary) + .withJoinFilterDialect(filterDialect) + .withSubqueryFilterDialect(filterDialect) + .withISO8601Dates("yyyy-MM-dd'T'HH:mm'Z'", TimeZone.getTimeZone("UTC")) + .build(); + } + + @Test + public void testInlineFragment() { + GraphQLEntityProjectionMaker maker = new GraphQLEntityProjectionMaker(settings); + + String query = "{ character { edges { node { " + + "__typename ... on Character { name } " + + "__typename ... on Droid { primaryFunction }}}}}"; + + GraphQLProjectionInfo info = maker.make(query); + + EntityProjection projection = info.getProjection("", "character"); + + Attribute nameAttribute = projection.getAttributes().stream() + .filter(attr -> attr.getName().equals("name")) + .findFirst() + .orElseThrow(() -> new IllegalStateException()); + + assertEquals(Character.class, nameAttribute.getParentType()); + + Attribute primaryFunctionAttribute = projection.getAttributes().stream() + .filter(attr -> attr.getName().equals("primaryFunction")) + .findFirst() + .orElseThrow(() -> new IllegalStateException()); + + assertEquals(Droid.class, primaryFunctionAttribute.getParentType()); + } +} diff --git a/elide-graphql/src/test/java/com/yahoo/elide/graphql/GraphQLNameUtilsTest.java b/elide-graphql/src/test/java/com/yahoo/elide/graphql/GraphQLNameUtilsTest.java new file mode 100644 index 0000000000..3c6f923ea7 --- /dev/null +++ b/elide-graphql/src/test/java/com/yahoo/elide/graphql/GraphQLNameUtilsTest.java @@ -0,0 +1,35 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.graphql; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.yahoo.elide.core.EntityDictionary; +import example.models.inheritance.Character; +import example.models.inheritance.Droid; +import example.models.inheritance.Hero; +import org.junit.jupiter.api.Test; + +import java.util.Collections; + +public class GraphQLNameUtilsTest { + + @Test + public void testBoundNameMapping() { + EntityDictionary dictionary = new EntityDictionary(Collections.EMPTY_MAP); + + dictionary.bindEntity(Droid.class); + dictionary.bindEntity(Hero.class); + dictionary.bindEntity(Character.class); + + GraphQLNameUtils nameUtils = new GraphQLNameUtils(dictionary); + + assertEquals("droid", nameUtils.toBoundName("Droid")); + assertEquals("hero", nameUtils.toBoundName("Hero")); + assertEquals("character", nameUtils.toBoundName("Character")); + } +} diff --git a/elide-graphql/src/test/java/com/yahoo/elide/graphql/GraphQLTest.java b/elide-graphql/src/test/java/com/yahoo/elide/graphql/GraphQLTest.java index 417b4678ab..78d201abc6 100644 --- a/elide-graphql/src/test/java/com/yahoo/elide/graphql/GraphQLTest.java +++ b/elide-graphql/src/test/java/com/yahoo/elide/graphql/GraphQLTest.java @@ -14,6 +14,9 @@ import example.Book; import example.Pseudonym; import example.Publisher; +import example.models.inheritance.Character; +import example.models.inheritance.Droid; +import example.models.inheritance.Hero; import java.util.HashMap; import java.util.Map; @@ -35,5 +38,8 @@ public GraphQLTest() { dictionary.bindEntity(Publisher.class); dictionary.bindEntity(Pseudonym.class); dictionary.bindEntity(Address.class); + dictionary.bindEntity(Hero.class); + dictionary.bindEntity(Droid.class); + dictionary.bindEntity(Character.class); } } diff --git a/elide-graphql/src/test/java/com/yahoo/elide/graphql/ModelBuilderTest.java b/elide-graphql/src/test/java/com/yahoo/elide/graphql/ModelBuilderTest.java index bdcfb73b93..d2347cf509 100644 --- a/elide-graphql/src/test/java/com/yahoo/elide/graphql/ModelBuilderTest.java +++ b/elide-graphql/src/test/java/com/yahoo/elide/graphql/ModelBuilderTest.java @@ -17,12 +17,15 @@ import com.yahoo.elide.core.ArgumentType; import com.yahoo.elide.core.EntityDictionary; - import com.yahoo.elide.request.Sorting; + import example.Author; import example.Book; import example.Publisher; +import example.models.inheritance.Character; +import example.models.inheritance.Droid; +import example.models.inheritance.Hero; import org.junit.jupiter.api.Test; import graphql.Scalars; @@ -30,6 +33,7 @@ import graphql.schema.GraphQLEnumType; import graphql.schema.GraphQLFieldDefinition; import graphql.schema.GraphQLInputObjectType; +import graphql.schema.GraphQLInterfaceType; import graphql.schema.GraphQLList; import graphql.schema.GraphQLObjectType; import graphql.schema.GraphQLSchema; @@ -57,6 +61,9 @@ public class ModelBuilderTest { private static final String PAGE_INFO = "pageInfo"; private static final String TYPE_QUERY = "Query"; + private static final String TYPE_CHARACTER = "Character"; + private static final String TYPE_DROID = "Droid"; + private static final String TYPE_HERO = "Hero"; private static final String TYPE_BOOK_CONNECTION = "BookConnection"; private static final String TYPE_BOOK_INPUT = "BookInput"; private static final String TYPE_BOOK = "Book"; @@ -92,6 +99,9 @@ public ModelBuilderTest() { dictionary.bindEntity(Book.class); dictionary.bindEntity(Author.class); dictionary.bindEntity(Publisher.class); + dictionary.bindEntity(Droid.class); + dictionary.bindEntity(Hero.class); + dictionary.bindEntity(Character.class); } @Test @@ -148,11 +158,11 @@ public void testBuild() { GraphQLSchema schema = builder.build(); - assertNotEquals(schema.getType(TYPE_AUTHOR_CONNECTION), null); - assertNotEquals(schema.getType(TYPE_BOOK_CONNECTION), null); - assertNotEquals(schema.getType(TYPE_AUTHOR_INPUT), null); - assertNotEquals(schema.getType(TYPE_BOOK_INPUT), null); - assertNotEquals(schema.getType(TYPE_QUERY), null); + assertNotEquals(null, schema.getType(TYPE_AUTHOR_CONNECTION)); + assertNotEquals(null, schema.getType(TYPE_BOOK_CONNECTION)); + assertNotEquals(null, schema.getType(TYPE_AUTHOR_INPUT)); + assertNotEquals(null, schema.getType(TYPE_BOOK_INPUT)); + assertNotEquals(null, schema.getType(TYPE_QUERY)); GraphQLObjectType bookType = getConnectedType((GraphQLObjectType) schema.getType(TYPE_BOOK_CONNECTION), null); GraphQLObjectType authorType = getConnectedType((GraphQLObjectType) schema.getType(TYPE_AUTHOR_CONNECTION), null); @@ -199,6 +209,25 @@ public void testBuild() { assertTrue(booksInputType.getWrappedType().equals(bookInputType)); } + @Test + public void testInterfaces() { + DataFetcher fetcher = mock(DataFetcher.class); + ModelBuilder builder = new ModelBuilder(dictionary, fetcher, NO_VERSION); + + GraphQLSchema schema = builder.build(); + assertNotEquals(null, schema.getType(TYPE_CHARACTER)); + assertNotEquals(null, schema.getType(TYPE_DROID)); + assertNotEquals(null, schema.getType(TYPE_HERO)); + GraphQLInterfaceType characterType = (GraphQLInterfaceType) schema.getType(TYPE_CHARACTER); + assertEquals("DeferredID", characterType.getFieldDefinition("name").getType().getName()); + + GraphQLObjectType heroType = (GraphQLObjectType) schema.getType(TYPE_HERO); + assertTrue(heroType.getFieldDefinition("forceSensitive").getType().equals(Scalars.GraphQLBoolean)); + + GraphQLObjectType droidType = (GraphQLObjectType) schema.getType(TYPE_DROID); + assertTrue(droidType.getFieldDefinition("primaryFunction").getType().equals(Scalars.GraphQLString)); + } + @Test public void checkAttributeArguments() { Set arguments = new HashSet<>(); diff --git a/elide-integration-tests/src/test/java/com/yahoo/elide/inheritance/InheritanceIT.java b/elide-integration-tests/src/test/java/com/yahoo/elide/inheritance/InheritanceIT.java index 850334055f..dd18473a0f 100644 --- a/elide-integration-tests/src/test/java/com/yahoo/elide/inheritance/InheritanceIT.java +++ b/elide-integration-tests/src/test/java/com/yahoo/elide/inheritance/InheritanceIT.java @@ -7,7 +7,16 @@ package com.yahoo.elide.inheritance; import static com.yahoo.elide.Elide.JSONAPI_CONTENT_TYPE; +import static com.yahoo.elide.contrib.testhelpers.graphql.GraphQLDSL.argument; +import static com.yahoo.elide.contrib.testhelpers.graphql.GraphQLDSL.arguments; +import static com.yahoo.elide.contrib.testhelpers.graphql.GraphQLDSL.document; +import static com.yahoo.elide.contrib.testhelpers.graphql.GraphQLDSL.field; +import static com.yahoo.elide.contrib.testhelpers.graphql.GraphQLDSL.mutation; +import static com.yahoo.elide.contrib.testhelpers.graphql.GraphQLDSL.selection; +import static com.yahoo.elide.contrib.testhelpers.graphql.GraphQLDSL.selections; +import static com.yahoo.elide.contrib.testhelpers.jsonapi.JsonApiDSL.attr; import static com.yahoo.elide.contrib.testhelpers.jsonapi.JsonApiDSL.attributes; +import static com.yahoo.elide.contrib.testhelpers.jsonapi.JsonApiDSL.data; import static com.yahoo.elide.contrib.testhelpers.jsonapi.JsonApiDSL.datum; import static com.yahoo.elide.contrib.testhelpers.jsonapi.JsonApiDSL.id; import static com.yahoo.elide.contrib.testhelpers.jsonapi.JsonApiDSL.linkage; @@ -16,19 +25,82 @@ import static com.yahoo.elide.contrib.testhelpers.jsonapi.JsonApiDSL.resource; import static com.yahoo.elide.contrib.testhelpers.jsonapi.JsonApiDSL.type; import static io.restassured.RestAssured.given; +import static org.apache.http.HttpStatus.SC_OK; import static org.hamcrest.Matchers.contains; import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.startsWith; +import com.yahoo.elide.contrib.testhelpers.graphql.VariableFieldSerializer; import com.yahoo.elide.core.HttpStatus; +import com.yahoo.elide.initialization.GraphQLTestUtils; import com.yahoo.elide.initialization.IntegrationTest; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; +import lombok.Data; import lombok.extern.slf4j.Slf4j; +import java.util.HashMap; + @Slf4j +@Tag("skipInMemory") //In memory store doesn't support inheritance. public class InheritanceIT extends IntegrationTest { + private GraphQLTestUtils testUtils = new GraphQLTestUtils(); + + @Data + private static class Droid { + @JsonSerialize(using = VariableFieldSerializer.class, as = String.class) + private String name; + + @JsonSerialize(using = VariableFieldSerializer.class, as = String.class) + private String primaryFunction; + } + + @BeforeEach + public void createCharacters() { + given() + .contentType(JSONAPI_CONTENT_TYPE) + .accept(JSONAPI_CONTENT_TYPE) + .body( + datum( + resource( + type("droid"), + id("C3P0"), + attributes( + attr("primaryFunction", "protocol droid") + ) + ) + ) + ) + .post("/droid") + .then() + .statusCode(HttpStatus.SC_CREATED) + .body("data.id", equalTo("C3P0")); + + given() + .contentType(JSONAPI_CONTENT_TYPE) + .accept(JSONAPI_CONTENT_TYPE) + .body( + datum( + resource( + type("hero"), + id("Luke Skywalker"), + attributes( + attr("forceSensitive", true) + ) + ) + ) + ) + .post("/hero") + .then() + .statusCode(HttpStatus.SC_CREATED) + .body("data.id", equalTo("Luke Skywalker")); + } + @Test public void testEmployeeHierarchy() { @@ -77,10 +149,258 @@ public void testEmployeeHierarchy() { .when() .get("/manager/1") .then() - .statusCode(org.apache.http.HttpStatus.SC_OK) + .statusCode(SC_OK) .body("data.id", equalTo("1"), "data.relationships.minions.data.id", contains("1"), "data.relationships.minions.data.type", contains("employee") ); } + + @Test + public void testJsonApiCharacterHierarchy() { + given() + .contentType(JSONAPI_CONTENT_TYPE) + .when() + .get("/character") + .then() + .statusCode(SC_OK) + .body(equalTo(data( + resource( + type("droid"), + id("C3P0"), + attributes( + attr("primaryFunction", "protocol droid") + ) + ), + resource( + type("hero"), + id("Luke Skywalker"), + attributes( + attr("forceSensitive", true) + ) + ) + ).toJSON())); + } + + @Test + public void testGraphQLCharacterHierarchy() throws Exception { + + String query = document( + selection( + field( + "character", + selections( + field("name") + ) + ) + ) + ).toQuery(); + + String expected = document( + selections( + field( + "character", + selections( + field("name", "C3P0") + ), + selections( + field("name", "Luke Skywalker") + ) + ) + ) + ).toResponse(); + + testUtils.runQueryWithExpectedResult(query, expected); + } + + @Test + public void testGraphQLDroidFragment() throws Exception { + + String query = "{ character { edges { node { " + + "__typename ... on Character { name } " + + "__typename ... on Droid { primaryFunction }}}}}"; + + String expected = document( + selections( + field( + "character", + selections( + field("__typename", "Droid"), + field("name", "C3P0"), + field("primaryFunction", "protocol droid") + ), + selections( + field("__typename", "Hero"), + field("name", "Luke Skywalker") + ) + ) + ) + ).toResponse(); + + testUtils.runQueryWithExpectedResult(query, expected); + } + + @Test + public void testGraphQLInvalidFragment() throws Exception { + + String query = "{ character { edges { node { " + + "__typename ... on Manager { id } " + + "__typename ... on Droid { primaryFunction }}}}}"; + + testUtils.runQuery(query, new HashMap<>()) + .statusCode(SC_OK) + .body(startsWith("{\"errors\":[{\"message\":\"Validation error of type InvalidFragmentType: ")); + } + + + @Test + public void testGraphQLCharacterUpsertFailure() throws Exception { + Droid droid = new Droid(); + droid.setName("IG-88"); + + String query = document( + mutation( + selection( + field( + "character", + arguments( + argument("op", "UPSERT"), + argument("data", droid) + ), + selections( + field("name") + ) + ) + ) + ) + ).toQuery(); + + String expected = "{\"data\":null,\"errors\":[{\"message\":\"Exception while fetching data (/character) " + + ": Bad Request Body'Cannot create an entity model of " + + "type: Character'\",\"locations\":[{\"line\":1,\"column\":11}],\"path\":[\"character\"]}]}"; + testUtils.runQueryWithExpectedResult(query, expected); + } + + @Test + public void testGraphQLCharacterInvalidFilter() throws Exception { + + String query = document( + selection( + field( + "character", + arguments( + argument("op", "FETCH"), + argument("filter", "primaryFunction==*protocol*", true) + ), + selections( + field("name") + ) + ) + )).toQuery(); + + String expected = "{\"errors\":[{\"message\":\"Could not parse filter primaryFunction==*protocol* for " + + "type: character. reason: Invalid filter format: filter[character]\\nNo such association " + + "primaryFunction for type character\"}]}"; + + testUtils.runQueryWithExpectedResult(query, expected); + } + + @Test + public void testJsonApiCharacterInvalidFilter() { + String expected = "{\"errors\":[{\"detail\":\"Invalid filter format: filter\\nNo such association " + + "primaryFunction for type character\\nInvalid filter format: " + + "filter\\nInvalid query parameter: filter\"}]}"; + + given() + .contentType(JSONAPI_CONTENT_TYPE) + .when() + .get("/character?filter=primaryFunction==*droid*") + .then() + .statusCode(org.apache.http.HttpStatus.SC_BAD_REQUEST) + .body(equalTo(expected)); + } + + @Test + public void testJsonApiCharacterHierarchySparseFields() { + given() + .contentType(JSONAPI_CONTENT_TYPE) + .when() + .get("/character?fields[droid]=primaryFunction&fields[hero]=id") + .then() + .statusCode(SC_OK) + .body(equalTo(data( + resource( + type("droid"), + id("C3P0"), + attributes( + attr("primaryFunction", "protocol droid") + ) + ), + resource( + type("hero"), + id("Luke Skywalker") + ) + ).toJSON())); + } + + @Test + public void testJsonApiCharacterInvalidCreation() { + String expected = "{\"errors\":[{\"detail\":\"Bad Request Body'Cannot create an " + + "entity model of type: Character'\"}]}"; + + given() + .contentType(JSONAPI_CONTENT_TYPE) + .accept(JSONAPI_CONTENT_TYPE) + .body( + datum( + resource( + type("character"), + id("IG-88"), + attributes( + attr("primaryFunction", "assassin droid") + ) + ) + ) + ) + .post("/character") + .then() + .statusCode(HttpStatus.SC_BAD_REQUEST) + .body(equalTo(expected)); + } + + @Test + public void testGraphQLCharacterInvalidSort() throws Exception { + + String query = document( + selection( + field( + "character", + arguments( + argument("op", "FETCH"), + argument("sort", "-primaryFunction", true) + ), + selections( + field("name") + ) + ) + )).toQuery(); + + String expected = "{\"errors\":[{\"message\":\"Invalid sorting clause -primaryFunction for type character\"}]}"; + + testUtils.runQueryWithExpectedResult(query, expected); + } + + @Test + public void testJsonApiCharacterInvalidSort() { + String expected = "{\"errors\":[{\"detail\":\"Invalid value: character does " + + "not contain the field primaryFunction\"}]}"; + + given() + .contentType(JSONAPI_CONTENT_TYPE) + .when() + .get("/character?sort=-primaryFunction") + .then() + .statusCode(org.apache.http.HttpStatus.SC_BAD_REQUEST) + .body(equalTo(expected)); + } } diff --git a/elide-integration-tests/src/test/java/com/yahoo/elide/initialization/GraphQLTestUtils.java b/elide-integration-tests/src/test/java/com/yahoo/elide/initialization/GraphQLTestUtils.java new file mode 100644 index 0000000000..23bbc20675 --- /dev/null +++ b/elide-integration-tests/src/test/java/com/yahoo/elide/initialization/GraphQLTestUtils.java @@ -0,0 +1,101 @@ +/* + * Copyright 2019, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.initialization; + +import static com.yahoo.elide.core.EntityDictionary.NO_VERSION; +import static io.restassured.RestAssured.given; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.yahoo.elide.core.HttpStatus; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.ObjectNode; +import io.restassured.response.ValidatableResponse; + +import java.io.IOException; +import java.util.Map; +import javax.ws.rs.core.MediaType; + +public class GraphQLTestUtils { + + private ObjectMapper mapper = new ObjectMapper(); + + public void runQueryWithExpectedResult( + String graphQLQuery, + Map variables, + String expected + ) throws IOException { + compareJsonObject(runQuery(graphQLQuery, variables), expected); + } + + public void runQueryWithExpectedResult( + String graphQLQuery, + Map variables, + String expected, + String apiVersion + ) throws IOException { + compareJsonObject(runQuery(graphQLQuery, variables, apiVersion), expected); + } + + public void runQueryWithExpectedResult(String graphQLQuery, String expected) throws IOException { + runQueryWithExpectedResult(graphQLQuery, null, expected); + } + + public void compareJsonObject(ValidatableResponse response, String expected) throws IOException { + JsonNode responseNode = mapper.readTree(response.extract().body().asString()); + JsonNode expectedNode = mapper.readTree(expected); + assertEquals(expectedNode, responseNode); + } + + public ValidatableResponse runQuery(String query, Map variables) throws IOException { + return runQuery(toJsonQuery(query, variables), NO_VERSION); + } + + public ValidatableResponse runQuery(String query, Map variables, String apiVersion) + throws IOException { + return runQuery(toJsonQuery(query, variables), apiVersion); + } + + public ValidatableResponse runQuery(String query, String apiVersion) { + return given() + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .header("ApiVersion", apiVersion) + .body(query) + .post("/graphQL") + .then() + .statusCode(HttpStatus.SC_OK); + } + + public String toJsonArray(JsonNode... nodes) throws IOException { + ArrayNode arrayNode = JsonNodeFactory.instance.arrayNode(); + for (JsonNode node : nodes) { + arrayNode.add(node); + } + return mapper.writeValueAsString(arrayNode); + } + + public String toJsonQuery(String query, Map variables) throws IOException { + return mapper.writeValueAsString(toJsonNode(query, variables)); + } + + public JsonNode toJsonNode(String query) { + return toJsonNode(query, null); + } + + public JsonNode toJsonNode(String query, Map variables) { + ObjectNode graphqlNode = JsonNodeFactory.instance.objectNode(); + graphqlNode.put("query", query); + if (variables != null) { + graphqlNode.set("variables", mapper.valueToTree(variables)); + } + return graphqlNode; + } +} diff --git a/elide-integration-tests/src/test/java/com/yahoo/elide/initialization/InMemoryDataStoreHarness.java b/elide-integration-tests/src/test/java/com/yahoo/elide/initialization/InMemoryDataStoreHarness.java index 961c2f39e3..c3e99d6289 100644 --- a/elide-integration-tests/src/test/java/com/yahoo/elide/initialization/InMemoryDataStoreHarness.java +++ b/elide-integration-tests/src/test/java/com/yahoo/elide/initialization/InMemoryDataStoreHarness.java @@ -16,6 +16,7 @@ import example.Parent; import example.models.generics.Manager; +import example.models.inheritance.Droid; import example.models.triggers.Invoice; import example.models.versioned.BookV2; @@ -35,6 +36,7 @@ public InMemoryDataStoreHarness() { Manager.class.getPackage(), BookV2.class.getPackage(), AsyncQuery.class.getPackage() + Droid.class.getPackage(), ); mapStore = new HashMapDataStore(beanPackages); diff --git a/elide-integration-tests/src/test/java/com/yahoo/elide/tests/GraphQLIT.java b/elide-integration-tests/src/test/java/com/yahoo/elide/tests/GraphQLIT.java index 912f5db98a..1da5f0f155 100644 --- a/elide-integration-tests/src/test/java/com/yahoo/elide/tests/GraphQLIT.java +++ b/elide-integration-tests/src/test/java/com/yahoo/elide/tests/GraphQLIT.java @@ -20,24 +20,19 @@ import static io.restassured.RestAssured.given; import static org.hamcrest.Matchers.containsInAnyOrder; import static org.hamcrest.Matchers.equalTo; -import static org.junit.jupiter.api.Assertions.assertEquals; import com.yahoo.elide.contrib.testhelpers.graphql.VariableFieldSerializer; import com.yahoo.elide.core.HttpStatus; +import com.yahoo.elide.initialization.GraphQLTestUtils; import com.yahoo.elide.initialization.IntegrationTest; -import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.annotation.JsonSerialize; -import com.fasterxml.jackson.databind.node.ArrayNode; -import com.fasterxml.jackson.databind.node.JsonNodeFactory; -import com.fasterxml.jackson.databind.node.ObjectNode; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; -import io.restassured.response.ValidatableResponse; import lombok.Getter; import lombok.Setter; @@ -55,6 +50,8 @@ */ public class GraphQLIT extends IntegrationTest { + private GraphQLTestUtils testUtils = new GraphQLTestUtils(); + private static class Book { @Getter @Setter @@ -138,7 +135,7 @@ public void createBookAndAuthor() throws IOException { ) ).toResponse(); - runQueryWithExpectedResult(graphQLQuery, expectedResponse); + testUtils.runQueryWithExpectedResult(graphQLQuery, expectedResponse); } @Test @@ -209,7 +206,7 @@ public void createWithVariables() throws IOException { variables.put("bookName", "Grapes of Wrath"); variables.put("authorName", "John Setinbeck"); - runQueryWithExpectedResult(graphQLRequest, variables, expected); + testUtils.runQueryWithExpectedResult(graphQLRequest, variables, expected); } @Test @@ -266,7 +263,7 @@ public void fetchCollection() throws IOException { ) ).toResponse(); - runQueryWithExpectedResult(graphQLRequest, expected); + testUtils.runQueryWithExpectedResult(graphQLRequest, expected); } @Test @@ -328,7 +325,7 @@ public void fetchRootSingle() throws IOException { ) ).toResponse(); - runQueryWithExpectedResult(graphQLRequest, expectedResponse); + testUtils.runQueryWithExpectedResult(graphQLRequest, expectedResponse); } @Test @@ -389,10 +386,9 @@ public void runUpdateAndFetchDifferentTransactionsBatch() throws IOException { ) ).toResponse(); - compareJsonObject( - runQuery(toJsonArray(toJsonNode(graphQLRequest1), toJsonNode(graphQLRequest2)), NO_VERSION), - expectedResponse - ); + testUtils.compareJsonObject(testUtils.runQuery(testUtils.toJsonArray( + testUtils.toJsonNode(graphQLRequest1), + testUtils.toJsonNode(graphQLRequest2)), NO_VERSION), expectedResponse); } @Test @@ -438,7 +434,7 @@ public void runMultipleRequestsSameTransactionWithAliases() throws IOException { ) ).toResponse(); - runQueryWithExpectedResult(graphQLRequest, expectedResponse); + testUtils.runQueryWithExpectedResult(graphQLRequest, expectedResponse); } @Tag("skipInMemory") //Elide doesn't support to-many filter joins in memory yet. @@ -505,7 +501,7 @@ public void testTypeIntrospection() throws Exception { + "}" + "}"; - String query = toJsonQuery(graphQLRequest, new HashMap<>()); + String query = testUtils.toJsonQuery(graphQLRequest, new HashMap<>()); given() .contentType(MediaType.APPLICATION_JSON) @@ -530,7 +526,7 @@ public void testVersionedTypeIntrospection() throws Exception { + "}" + "}"; - String query = toJsonQuery(graphQLRequest, new HashMap<>()); + String query = testUtils.toJsonQuery(graphQLRequest, new HashMap<>()); given() .contentType(MediaType.APPLICATION_JSON) @@ -572,7 +568,7 @@ public void fetchCollectionVersioned() throws IOException { ) ).toResponse(); - runQueryWithExpectedResult(graphQLRequest, null, expected, "1.0"); + testUtils.runQueryWithExpectedResult(graphQLRequest, null, expected, "1.0"); } @Test @@ -592,7 +588,7 @@ public void testInvalidApiVersion() throws IOException { String expected = "{\"errors\":[{\"message\":\"Invalid operation: Invalid API Version\"}]}"; - String query = toJsonQuery(graphQLRequest, new HashMap<>()); + String query = testUtils.toJsonQuery(graphQLRequest, new HashMap<>()); given() .contentType(MediaType.APPLICATION_JSON) @@ -621,79 +617,6 @@ public void testMissingVersionedModel() throws IOException { String expected = "{\"errors\":[{\"message\":\"Bad Request Body'Unknown entity {parent}.'\"}]}"; - runQueryWithExpectedResult(graphQLRequest, null, expected, "1.0"); - } - - - private void runQueryWithExpectedResult( - String graphQLQuery, - Map variables, - String expected - ) throws IOException { - compareJsonObject(runQuery(graphQLQuery, variables), expected); - } - - private void runQueryWithExpectedResult( - String graphQLQuery, - Map variables, - String expected, - String apiVersion - ) throws IOException { - compareJsonObject(runQuery(graphQLQuery, variables, apiVersion), expected); - } - - private void runQueryWithExpectedResult(String graphQLQuery, String expected) throws IOException { - runQueryWithExpectedResult(graphQLQuery, null, expected); - } - - private void compareJsonObject(ValidatableResponse response, String expected) throws IOException { - JsonNode responseNode = mapper.readTree(response.extract().body().asString()); - JsonNode expectedNode = mapper.readTree(expected); - assertEquals(expectedNode, responseNode); - } - - private ValidatableResponse runQuery(String query, Map variables) throws IOException { - return runQuery(toJsonQuery(query, variables), NO_VERSION); - } - - private ValidatableResponse runQuery(String query, Map variables, String apiVersion) - throws IOException { - return runQuery(toJsonQuery(query, variables), apiVersion); - } - - private ValidatableResponse runQuery(String query, String apiVersion) { - return given() - .contentType(MediaType.APPLICATION_JSON) - .accept(MediaType.APPLICATION_JSON) - .header("ApiVersion", apiVersion) - .body(query) - .post("/graphQL") - .then() - .statusCode(HttpStatus.SC_OK); - } - - private String toJsonArray(JsonNode... nodes) throws IOException { - ArrayNode arrayNode = JsonNodeFactory.instance.arrayNode(); - for (JsonNode node : nodes) { - arrayNode.add(node); - } - return mapper.writeValueAsString(arrayNode); - } - - private String toJsonQuery(String query, Map variables) throws IOException { - return mapper.writeValueAsString(toJsonNode(query, variables)); - } - - private JsonNode toJsonNode(String query) { - return toJsonNode(query, null); - } - - private JsonNode toJsonNode(String query, Map variables) { - ObjectNode graphqlNode = JsonNodeFactory.instance.objectNode(); - graphqlNode.put("query", query); - if (variables != null) { - graphqlNode.set("variables", mapper.valueToTree(variables)); - } - return graphqlNode; + testUtils.runQueryWithExpectedResult(graphQLRequest, null, expected, "1.0"); } }