/* * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. * * Licensed under the Apache License, Version 2.0 (the "License"). * You may not use this file except in compliance with the License. * A copy of the License is located at * * http://aws.amazon.com/apache2.0 * * or in the "license" file accompanying this file. This file 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 com.amplifyframework.api.aws; import android.text.TextUtils; import androidx.annotation.NonNull; import androidx.core.util.ObjectsCompat; import com.amplifyframework.AmplifyException; import com.amplifyframework.api.graphql.Operation; import com.amplifyframework.api.graphql.QueryType; import com.amplifyframework.core.model.AuthRule; import com.amplifyframework.core.model.AuthStrategy; import com.amplifyframework.core.model.CustomTypeField; import com.amplifyframework.core.model.CustomTypeSchema; import com.amplifyframework.core.model.Model; import com.amplifyframework.core.model.ModelAssociation; import com.amplifyframework.core.model.ModelField; import com.amplifyframework.core.model.ModelSchema; import com.amplifyframework.core.model.SchemaRegistry; import com.amplifyframework.core.model.SerializedModel; import com.amplifyframework.core.model.types.JavaFieldType; import com.amplifyframework.util.Empty; import com.amplifyframework.util.FieldFinder; import com.amplifyframework.util.Wrap; import java.lang.reflect.Field; import java.lang.reflect.ParameterizedType; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.HashSet; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; /** * Class representing a node of a SelectionSet for use in a GraphQLDocument. * A root SelectionSet node will have a null value. */ public final class SelectionSet { private static final String INDENT = " "; private final String value; private final Set nodes; /** * Copy constructor. * @param selectionSet node to copy */ @SuppressWarnings("CopyConstructorMissesField") // It is cloned, by recursion public SelectionSet(SelectionSet selectionSet) { this(selectionSet.value, new HashSet<>(selectionSet.nodes)); } /** * Constructor for a leaf node (no children). * @param value String value of the field. */ public SelectionSet(String value) { this(value, Collections.emptySet()); } /** * Default constructor. * @param value String value of the field * @param nodes Set of child nodes */ public SelectionSet(String value, @NonNull Set nodes) { this.value = value; this.nodes = Objects.requireNonNull(nodes); } /** * Returns child nodes. * @return child nodes */ @NonNull public Set getNodes() { return nodes; } /** * Generate the String value of the SelectionSet used in the GraphQL query document, with no margin. * * Sample return value: * items { * foo * bar * modelName { * foo * bar * } * } * nextToken * * @return String value of the selection set for a GraphQL query document. */ @Override public String toString() { return toString(""); } /** * Generates the String value of the SelectionSet for a GraphQL query document. * @param margin a margin with which to prefix each field of the selection set. * @return String value of the SelectionSet for a GraphQL query document. */ public String toString(String margin) { List fieldsList = new ArrayList<>(); StringBuilder builder = new StringBuilder(); if (value != null) { builder.append(value); } if (!Empty.check(nodes)) { for (SelectionSet node : nodes) { fieldsList.add(node.toString(margin + INDENT)); } Collections.sort(fieldsList); String delimiter = "\n" + margin + INDENT; builder.append(Wrap.inPrettyBraces(TextUtils.join(delimiter, fieldsList), margin, INDENT)); } return builder.toString(); } @Override public boolean equals(Object object) { if (this == object) { return true; } if (object == null || getClass() != object.getClass()) { return false; } SelectionSet selectionSet = (SelectionSet) object; return ObjectsCompat.equals(value, selectionSet.value); } @Override public int hashCode() { return ObjectsCompat.hash(value); } /** * Create a new SelectionSet builder. * @return a new SelectionSet builder. */ public static SelectionSet.Builder builder() { return new Builder(); } /** * Factory class for creating and serializing a selection set within a GraphQL document. */ static final class Builder { private Class modelClass; private Operation operation; private GraphQLRequestOptions requestOptions; private ModelSchema modelSchema; Builder() { } public Builder modelClass(@NonNull Class modelClass) { this.modelClass = Objects.requireNonNull(modelClass); return Builder.this; } public Builder modelSchema(@NonNull ModelSchema modelSchema) { this.modelSchema = Objects.requireNonNull(modelSchema); return Builder.this; } public Builder operation(@NonNull Operation operation) { this.operation = Objects.requireNonNull(operation); return Builder.this; } public Builder requestOptions(@NonNull GraphQLRequestOptions requestOptions) { this.requestOptions = Objects.requireNonNull(requestOptions); return Builder.this; } /** * Builds the SelectionSet containing all of the fields of the provided model class. * @return selection set * @throws AmplifyException if a ModelSchema cannot be created from the provided model class. */ public SelectionSet build() throws AmplifyException { if (this.modelClass == null && this.modelSchema == null) { throw new AmplifyException("Both modelClass and modelSchema cannot be null", "Provide either a modelClass or a modelSchema to build the selection set"); } Objects.requireNonNull(this.operation); SelectionSet node = new SelectionSet(null, SerializedModel.class == modelClass ? getModelFields(modelSchema, requestOptions.maxDepth(), operation) : getModelFields(modelClass, requestOptions.maxDepth(), operation)); if (QueryType.LIST.equals(operation) || QueryType.SYNC.equals(operation)) { node = wrapPagination(node); } return node; } /** * Expects a {@link SelectionSet} containing {@link Model} fields as nodes, and returns a new root node with two * children: * - "items" with nodes being the children of the provided node. * - "nextToken" * * @param node a root node, with a value of null, and pagination fields * @return A selection set */ private SelectionSet wrapPagination(SelectionSet node) { return new SelectionSet(null, wrapPagination(node.getNodes())); } private Set wrapPagination(Set nodes) { Set paginatedSet = new HashSet<>(); paginatedSet.add(new SelectionSet(requestOptions.listField(), nodes)); for (String metaField : requestOptions.paginationFields()) { paginatedSet.add(new SelectionSet(metaField)); } return paginatedSet; } /** * Gets a selection set for the given class. * TODO: this is mostly duplicative of {@link #getModelFields(ModelSchema, int, Operation)}. * Long-term, we want to remove this current method and rely only on the ModelSchema-based * version. * @param clazz Class from which to build selection set * @param depth Number of children deep to explore * @return Selection Set * @throws AmplifyException On failure to build selection set */ @SuppressWarnings("unchecked") // Cast to Class private Set getModelFields(Class clazz, int depth, Operation operation) throws AmplifyException { if (depth < 0) { return new HashSet<>(); } Set result = new HashSet<>(); ModelSchema schema = ModelSchema.fromModelClass(clazz); if (depth == 0 && LeafSerializationBehavior.JUST_ID.equals(requestOptions.leafSerializationBehavior()) && operation != QueryType.SYNC ) { for (String s : schema.getPrimaryIndexFields()) { result.add(new SelectionSet(s)); } return result; } for (Field field : FieldFinder.findModelFieldsIn(clazz)) { String fieldName = field.getName(); if (schema.getAssociations().containsKey(fieldName)) { if (List.class.isAssignableFrom(field.getType())) { if (depth >= 1) { ParameterizedType listType = (ParameterizedType) field.getGenericType(); Class listTypeClass = (Class) listType.getActualTypeArguments()[0]; Set fields = wrapPagination(getModelFields(listTypeClass, depth - 1, operation)); result.add(new SelectionSet(fieldName, fields)); } } else if (depth >= 1) { Set fields = getModelFields((Class) field.getType(), depth - 1, operation); result.add(new SelectionSet(fieldName, fields)); } } else if (isCustomType(field)) { result.add(new SelectionSet(fieldName, getNestedCustomTypeFields(getClassForField(field)))); } else { result.add(new SelectionSet(fieldName)); } for (AuthRule authRule : schema.getAuthRules()) { if (AuthStrategy.OWNER.equals(authRule.getAuthStrategy())) { result.add(new SelectionSet(authRule.getOwnerFieldOrDefault())); break; } } } for (String fieldName : requestOptions.modelMetaFields()) { result.add(new SelectionSet(fieldName)); } return result; } /** * We handle customType fields differently as DEPTH does not apply here. * @param clazz class we wish to build selection set for * @return A set of selection sets */ private Set getNestedCustomTypeFields(Class clazz) { Set result = new HashSet<>(); for (Field field : FieldFinder.findNonTransientFieldsIn(clazz)) { String fieldName = field.getName(); if (isCustomType(field)) { result.add(new SelectionSet(fieldName, getNestedCustomTypeFields(getClassForField(field)))); } else { result.add(new SelectionSet(fieldName)); } } return result; } /** * Helper to determine if field is a custom type. If custom types we need to build nested selection set. * @param field field we wish to check * @return True if the field is of a custom type */ private static boolean isCustomType(@NonNull Field field) { Class cls = getClassForField(field); if (Model.class.isAssignableFrom(cls) || Enum.class.isAssignableFrom(cls)) { return false; } try { JavaFieldType.from(cls); return false; } catch (IllegalArgumentException exception) { // if we get here then field is a custom type return true; } } /** * Get the class of a field. If field is a collection, it returns the Generic type * @return The class of the field */ static Class getClassForField(Field field) { Class typeClass; if (Collection.class.isAssignableFrom(field.getType())) { ParameterizedType listType = (ParameterizedType) field.getGenericType(); typeClass = (Class) listType.getActualTypeArguments()[0]; } else { typeClass = field.getType(); } return typeClass; } // TODO: this method is tech debt. We added it to support usage of the library from Flutter. // This version of the method needs to be unified with getModelFields(Class clazz, int depth). private Set getModelFields(ModelSchema modelSchema, int depth, Operation operation) { if (depth < 0) { return new HashSet<>(); } Set result = new HashSet<>(); if ( depth == 0 && LeafSerializationBehavior.JUST_ID.equals(requestOptions.leafSerializationBehavior()) && operation != QueryType.SYNC ) { Iterator primaryKeyIterator = modelSchema.getPrimaryIndexFields().listIterator(); if (primaryKeyIterator.hasNext()) { result.add(new SelectionSet(primaryKeyIterator.next())); } } SchemaRegistry modelSchemas = SchemaRegistry.instance(); for (Map.Entry entry : modelSchema.getFields().entrySet()) { String fieldName = entry.getKey(); ModelAssociation association = modelSchema.getAssociations().get(fieldName); if (association != null) { if (depth >= 1) { String associatedModelName = association.getAssociatedType(); ModelSchema associateModelSchema = modelSchemas.getModelSchemaForModelClass(associatedModelName); Set fields; if (entry.getValue().isArray()) { // If modelField is an Array fields = wrapPagination(getModelFields(associateModelSchema, depth - 1, operation)); } else { fields = getModelFields(associateModelSchema, depth - 1, operation); } result.add(new SelectionSet(fieldName, fields)); } } else if (entry.getValue().isCustomType()) { CustomTypeSchema fieldCustomTypeSchema = modelSchemas.getCustomTypeSchemaForCustomTypeClass(entry.getValue().getTargetType()); Set fields = getCustomTypeFields(fieldCustomTypeSchema); result.add(new SelectionSet(fieldName, fields)); } else { result.add(new SelectionSet(fieldName)); } for (AuthRule authRule : modelSchema.getAuthRules()) { if (AuthStrategy.OWNER.equals(authRule.getAuthStrategy())) { result.add(new SelectionSet(authRule.getOwnerFieldOrDefault())); break; } } } for (String fieldName : requestOptions.modelMetaFields()) { result.add(new SelectionSet(fieldName)); } return result; } private Set getCustomTypeFields(@NonNull CustomTypeSchema customTypeSchema) { SchemaRegistry schemaRegistry = SchemaRegistry.instance(); Set result = new HashSet<>(); for (Map.Entry entry : customTypeSchema.getFields().entrySet()) { String fieldName = entry.getKey(); if (entry.getValue().isCustomType()) { CustomTypeSchema fieldCustomTypeSchema = schemaRegistry.getCustomTypeSchemaForCustomTypeClass(entry.getValue().getTargetType()); Set fields = getCustomTypeFields(fieldCustomTypeSchema); result.add(new SelectionSet(fieldName, fields)); } else { result.add(new SelectionSet(fieldName)); } } return result; } } }