src/Service.GraphQLBuilder/GraphQLUtils.cs (294 lines of code) (raw):
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
using System.Diagnostics.CodeAnalysis;
using System.Net;
using Azure.DataApiBuilder.Config.ObjectModel;
using Azure.DataApiBuilder.Service.Exceptions;
using Azure.DataApiBuilder.Service.GraphQLBuilder.Directives;
using Azure.DataApiBuilder.Service.GraphQLBuilder.Queries;
using HotChocolate.Execution;
using HotChocolate.Language;
using HotChocolate.Resolvers;
using HotChocolate.Types;
using static Azure.DataApiBuilder.Service.GraphQLBuilder.GraphQLTypes.SupportedHotChocolateTypes;
namespace Azure.DataApiBuilder.Service.GraphQLBuilder
{
public static class GraphQLUtils
{
public const string DEFAULT_PRIMARY_KEY_NAME = "id";
public const string DEFAULT_PARTITION_KEY_NAME = "_partitionKeyValue";
public const string AUTHORIZE_DIRECTIVE = "authorize";
public const string AUTHORIZE_DIRECTIVE_ARGUMENT_ROLES = "roles";
public const string OBJECT_TYPE_MUTATION = "mutation";
public const string OBJECT_TYPE_QUERY = "query";
public const string SYSTEM_ROLE_ANONYMOUS = "anonymous";
public const string DB_OPERATION_RESULT_TYPE = "DbOperationResult";
public const string DB_OPERATION_RESULT_FIELD_NAME = "result";
// String used as a prefix for the name of a linking entity.
private const string LINKING_ENTITY_PREFIX = "LinkingEntity";
// Delimiter used to separate linking entity prefix/source entity name/target entity name, in the name of a linking entity.
private const string ENTITY_NAME_DELIMITER = "$";
public static HashSet<DatabaseType> RELATIONAL_DBS = new() { DatabaseType.MSSQL, DatabaseType.MySQL,
DatabaseType.DWSQL, DatabaseType.PostgreSQL, DatabaseType.CosmosDB_PostgreSQL };
public static bool IsModelType(ObjectTypeDefinitionNode objectTypeDefinitionNode)
{
string modelDirectiveName = ModelDirectiveType.DirectiveName;
return objectTypeDefinitionNode.Directives.Any(d => d.Name.ToString() == modelDirectiveName);
}
public static bool IsModelType(ObjectType objectType)
{
string modelDirectiveName = ModelDirectiveType.DirectiveName;
return objectType.Directives.Any(d => d.Name.ToString() == modelDirectiveName);
}
public static bool IsBuiltInType(ITypeNode typeNode)
{
HashSet<string> builtInTypes = new()
{
"ID", // Required for CosmosDB
UUID_TYPE,
BYTE_TYPE,
SHORT_TYPE,
INT_TYPE,
LONG_TYPE,
SINGLE_TYPE,
FLOAT_TYPE,
DECIMAL_TYPE,
STRING_TYPE,
BOOLEAN_TYPE,
DATETIME_TYPE,
BYTEARRAY_TYPE,
LOCALTIME_TYPE
};
string name = typeNode.NamedType().Name.Value;
return builtInTypes.Contains(name);
}
/// <summary>
/// Helper method to evaluate whether database type represents a NoSQL database.
/// </summary>
public static bool IsRelationalDb(DatabaseType databaseType)
{
return RELATIONAL_DBS.Contains(databaseType);
}
/// <summary>
/// Find all the primary keys for a given object node
/// using the information available in the directives.
/// If no directives present, default to a field named "id" as the primary key.
/// If even that doesn't exist, throw an exception in initialization.
/// </summary>
public static List<FieldDefinitionNode> FindPrimaryKeyFields(ObjectTypeDefinitionNode node, DatabaseType databaseType)
{
List<FieldDefinitionNode> fieldDefinitionNodes = new();
if (databaseType is DatabaseType.CosmosDB_NoSQL)
{
fieldDefinitionNodes.Add(
new FieldDefinitionNode(
location: null,
new NameNode(DEFAULT_PRIMARY_KEY_NAME),
new StringValueNode("Id value to provide to identify a cosmos db record"),
new List<InputValueDefinitionNode>(),
new IdType().ToTypeNode(),
new List<DirectiveNode>()));
fieldDefinitionNodes.Add(
new FieldDefinitionNode(
location: null,
new NameNode(DEFAULT_PARTITION_KEY_NAME),
new StringValueNode("Partition key value to provide to identify a cosmos db record"),
new List<InputValueDefinitionNode>(),
new StringType().ToTypeNode(),
new List<DirectiveNode>()));
}
else
{
fieldDefinitionNodes = new(node.Fields.Where(f => f.Directives.Any(d => d.Name.Value == PrimaryKeyDirectiveType.DirectiveName)));
// By convention we look for a `@primaryKey` directive, if that didn't exist
// fallback to using an expected field name on the GraphQL object
if (fieldDefinitionNodes.Count == 0)
{
FieldDefinitionNode? fieldDefinitionNode =
node.Fields.FirstOrDefault(f => f.Name.Value == DEFAULT_PRIMARY_KEY_NAME);
if (fieldDefinitionNode is not null)
{
fieldDefinitionNodes.Add(fieldDefinitionNode);
}
else
{
// Nothing explicitly defined nor could we find anything using our conventions, fail out
throw new DataApiBuilderException(
message: "No primary key defined and conventions couldn't locate a fallback",
subStatusCode: DataApiBuilderException.SubStatusCodes.ErrorInInitialization,
statusCode: HttpStatusCode.ServiceUnavailable);
}
}
}
return fieldDefinitionNodes;
}
/// <summary>
/// Checks if a field is auto generated by the database using the directives of the field definition.
/// </summary>
/// <param name="field">Field definition to check.</param>
/// <returns><c>true</c> if it is auto generated, <c>false</c> if it is not.</returns>
public static bool IsAutoGeneratedField(FieldDefinitionNode field)
{
return field.Directives.Any(d => d.Name.Value == AutoGeneratedDirectiveType.DirectiveName);
}
/// <summary>
/// Creates a HotChocolate/GraphQL Authorize directive with a list of roles (if any) provided.
/// Typically used to lock down Object/Field types to users who are members of the roles allowed.
/// Will not create such a directive if one of the roles is the system role: anonymous
/// since for that case, we don't want to lock down the field on which this directive is intended to be
/// added.
/// </summary>
/// <param name="roles">Collection of roles to set on the directive </param>
/// <param name="authorizeDirective">DirectiveNode set such that: @authorize(roles: ["role1", ..., "roleN"])
/// where none of role1,..roleN is anonymous. Otherwise, set to null.</param>
/// <returns>True if set to a new DirectiveNode, false otherwise. </returns>
public static bool CreateAuthorizationDirectiveIfNecessary(
IEnumerable<string>? roles,
out DirectiveNode? authorizeDirective)
{
// Any roles passed in will be added to the authorize directive for this field
// taking the form: @authorize(roles: [“role1”, ..., “roleN”])
// If the 'anonymous' role is present in the role list, no @authorize directive will be added
// because HotChocolate requires an authenticated user when the authorize directive is evaluated.
if (roles is not null &&
roles.Count() > 0 &&
!roles.Contains(SYSTEM_ROLE_ANONYMOUS))
{
List<IValueNode> roleList = new();
foreach (string rolename in roles)
{
roleList.Add(new StringValueNode(rolename));
}
ListValueNode roleListNode = new(items: roleList);
authorizeDirective =
new(name: AUTHORIZE_DIRECTIVE,
new ArgumentNode(name: AUTHORIZE_DIRECTIVE_ARGUMENT_ROLES, roleListNode));
return true;
}
else
{
authorizeDirective = null;
return false;
}
}
/// <summary>
/// Get the model name (EntityName) defined on the object type definition.
/// </summary>
/// <param name="fieldDirectives">Collection of directives on GraphQL field.</param>
/// <param name="modelName">Value of @model directive, if present.</param>
/// <returns>True when name resolution succeeded, false otherwise.</returns>
public static bool TryExtractGraphQLFieldModelName(IDirectiveCollection fieldDirectives, [NotNullWhen(true)] out string? modelName)
{
foreach (Directive dir in fieldDirectives)
{
if (dir.Name.Value == ModelDirectiveType.DirectiveName)
{
ModelDirectiveType modelDirectiveType = dir.ToObject<ModelDirectiveType>();
if (modelDirectiveType.Name.HasValue)
{
modelName = dir.GetArgument<string>(ModelDirectiveType.ModelNameArgument).ToString();
return modelName is not null;
}
}
}
modelName = null;
return false;
}
/// <summary>
/// UnderlyingGraphQLEntityType is the main GraphQL type that is described by
/// this type. This strips all modifiers, such as List and Non-Null.
/// So the following GraphQL types would all have the underlyingType Book:
/// - Book
/// - [Book]
/// - Book!
/// - [Book]!
/// - [Book!]!
/// </summary>
public static ObjectType UnderlyingGraphQLEntityType(IType type)
{
if (type is ObjectType underlyingType)
{
return underlyingType;
}
return UnderlyingGraphQLEntityType(type.InnerType());
}
/// <summary>
/// Generates the datasource name from the GraphQL context.
/// </summary>
/// <param name="context">Middleware context.</param>
/// <returns>Datasource name used to execute request.</returns>
public static string GetDataSourceNameFromGraphQLContext(IPureResolverContext context, RuntimeConfig runtimeConfig)
{
string rootNode = context.Selection.Field.Coordinate.TypeName.Value;
string dataSourceName;
if (string.Equals(rootNode, "mutation", StringComparison.OrdinalIgnoreCase) || string.Equals(rootNode, "query", StringComparison.OrdinalIgnoreCase))
{
// we are at the root query node - need to determine return type and store on context.
// Output type below would be the graphql object return type - Books,BooksConnectionObject.
string entityName = GetEntityNameFromContext(context);
dataSourceName = runtimeConfig.GetDataSourceNameFromEntityName(entityName);
// Store dataSourceName on context for later use.
context.ContextData.TryAdd(GenerateDataSourceNameKeyFromPath(context), dataSourceName);
}
else
{
// Derive node from path - e.g. /books/{id} - node would be books.
// for this queryNode path we have stored the datasourceName needed to retrieve query and mutation engine of inner objects
object? obj = context.ContextData[GenerateDataSourceNameKeyFromPath(context)];
if (obj is null)
{
throw new DataApiBuilderException(
message: $"Unable to determine datasource name for operation.",
statusCode: HttpStatusCode.InternalServerError,
subStatusCode: DataApiBuilderException.SubStatusCodes.GraphQLMapping);
}
dataSourceName = obj.ToString()!;
}
return dataSourceName;
}
/// <summary>
/// Get entity name from context object.
/// </summary>
public static string GetEntityNameFromContext(IPureResolverContext context)
{
IOutputType type = context.Selection.Field.Type;
string graphQLTypeName = type.TypeName();
string entityName = graphQLTypeName;
if (graphQLTypeName is DB_OPERATION_RESULT_TYPE)
{
// CUD for a mutation whose result set we do not have. Get Entity name mutation field directive.
if (TryExtractGraphQLFieldModelName(context.Selection.Field.Directives, out string? modelName))
{
entityName = modelName;
}
}
else
{
// for rest of scenarios get entity name from output object type.
ObjectType underlyingFieldType;
underlyingFieldType = GraphQLUtils.UnderlyingGraphQLEntityType(type);
// Example: CustomersConnectionObject - for get all scenarios.
if (QueryBuilder.IsPaginationType(underlyingFieldType))
{
IObjectField subField = GraphQLUtils.UnderlyingGraphQLEntityType(context.Selection.Field.Type).Fields[QueryBuilder.PAGINATION_FIELD_NAME];
type = subField.Type;
underlyingFieldType = GraphQLUtils.UnderlyingGraphQLEntityType(type);
entityName = underlyingFieldType.Name;
}
// if name on schema is different from name in config.
// Due to possibility of rename functionality, entityName on runtimeConfig could be different from exposed schema name.
if (TryExtractGraphQLFieldModelName(underlyingFieldType.Directives, out string? modelName))
{
entityName = modelName;
}
}
return entityName;
}
private static string GenerateDataSourceNameKeyFromPath(IPureResolverContext context)
{
return $"{context.Path.ToList()[0]}";
}
/// <summary>
/// Helper method to determine whether a field is a column (or scalar) or complex (relationship) field based on its syntax kind.
/// If the SyntaxKind for the field is not ObjectValue and ListValue, it implies we are dealing with a column/scalar field which
/// has an IntValue, FloatValue, StringValue, BooleanValue or an EnumValue.
/// </summary>
/// <param name="fieldSyntaxKind">SyntaxKind of the field.</param>
/// <returns>true if the field is a scalar field, else false.</returns>
public static bool IsScalarField(SyntaxKind fieldSyntaxKind)
{
return fieldSyntaxKind is SyntaxKind.IntValue || fieldSyntaxKind is SyntaxKind.FloatValue ||
fieldSyntaxKind is SyntaxKind.StringValue || fieldSyntaxKind is SyntaxKind.BooleanValue ||
fieldSyntaxKind is SyntaxKind.EnumValue;
}
/// <summary>
/// Helper method to get the field details i.e. (field value, field kind) from the GraphQL request body.
/// If the field value is being provided as a variable in the mutation, a recursive call is made to the method
/// to get the actual value of the variable.
/// </summary>
/// <param name="value">Value of the field.</param>
/// <param name="variables">Collection of variables declared in the GraphQL mutation request.</param>
/// <returns>A tuple containing a constant field value and the field kind.</returns>
public static Tuple<IValueNode?, SyntaxKind> GetFieldDetails(IValueNode? value, IVariableValueCollection variables)
{
if (value is null)
{
return new(null, SyntaxKind.NullValue);
}
if (value.Kind == SyntaxKind.Variable)
{
string variableName = ((VariableNode)value).Name.Value;
IValueNode? variableValue = variables.GetVariable<IValueNode>(variableName);
return GetFieldDetails(variableValue, variables);
}
return new(value, value.Kind);
}
/// <summary>
/// Helper method to generate the linking entity name using the source and target entity names.
/// </summary>
/// <param name="source">Source entity name.</param>
/// <param name="target">Target entity name.</param>
/// <returns>Name of the linking entity 'LinkingEntity$SourceEntityName$TargetEntityName'.</returns>
public static string GenerateLinkingEntityName(string source, string target)
{
return LINKING_ENTITY_PREFIX + ENTITY_NAME_DELIMITER + source + ENTITY_NAME_DELIMITER + target;
}
/// <summary>
/// Helper method to decode the names of source and target entities from the name of a linking entity.
/// </summary>
/// <param name="linkingEntityName">linking entity name of the format 'LinkingEntity$SourceEntityName$TargetEntityName'.</param>
/// <returns>tuple of source, target entities name of the format (SourceEntityName, TargetEntityName).</returns>
/// <exception cref="ArgumentException">Thrown when the linking entity name is not of the expected format.</exception>
public static Tuple<string, string> GetSourceAndTargetEntityNameFromLinkingEntityName(string linkingEntityName)
{
if (!linkingEntityName.StartsWith(LINKING_ENTITY_PREFIX + ENTITY_NAME_DELIMITER))
{
throw new ArgumentException("The provided entity name is an invalid linking entity name.");
}
string[] sourceTargetEntityNames = linkingEntityName.Split(ENTITY_NAME_DELIMITER, StringSplitOptions.RemoveEmptyEntries);
if (sourceTargetEntityNames.Length != 3)
{
throw new ArgumentException("The provided entity name is an invalid linking entity name.");
}
return new(sourceTargetEntityNames[1], sourceTargetEntityNames[2]);
}
/// <summary>
/// Helper method to extract a hotchocolate field node object with the specified name from all the field node objects belonging to an input type object.
/// </summary>
/// <param name="objectFieldNodes">List of field node objects belonging to an input type object</param>
/// <param name="fieldName"> Name of the field node object to extract from the list of all field node objects</param>
/// <exception cref="ArgumentException"></exception>
public static IValueNode GetFieldNodeForGivenFieldName(List<ObjectFieldNode> objectFieldNodes, string fieldName)
{
ObjectFieldNode? requiredFieldNode = objectFieldNodes.Where(fieldNode => fieldNode.Name.Value.Equals(fieldName)).FirstOrDefault();
if (requiredFieldNode != null)
{
return requiredFieldNode.Value;
}
throw new ArgumentException($"The provided field {fieldName} does not exist.");
}
/// <summary>
/// Helper method to determine if the relationship defined between the source entity and a particular target entity is an M:N relationship.
/// </summary>
/// <param name="sourceEntity">Source entity.</param>
/// <param name="relationshipName">Relationship name.</param>
/// <returns>true if the relationship between source and target entities has a cardinality of M:N.</returns>
public static bool IsMToNRelationship(Entity sourceEntity, string relationshipName)
{
return sourceEntity.Relationships is not null &&
sourceEntity.Relationships.TryGetValue(relationshipName, out EntityRelationship? relationshipInfo) &&
!string.IsNullOrWhiteSpace(relationshipInfo.LinkingObject);
}
/// <summary>
/// Helper method to get the name of the related entity for a given relationship name.
/// </summary>
/// <param name="entity">Entity object</param>
/// <param name="entityName">Name of the entity</param>
/// <param name="relationshipName">Name of the relationship</param>
/// <returns>Name of the related entity</returns>
public static string GetRelationshipTargetEntityName(Entity entity, string entityName, string relationshipName)
{
if (entity.Relationships is null)
{
throw new DataApiBuilderException(message: $"Entity {entityName} has no relationships defined",
statusCode: HttpStatusCode.InternalServerError,
subStatusCode: DataApiBuilderException.SubStatusCodes.UnexpectedError);
}
if (entity.Relationships.TryGetValue(relationshipName, out EntityRelationship? entityRelationship)
&& entityRelationship is not null)
{
return entityRelationship.TargetEntity;
}
else
{
throw new DataApiBuilderException(message: $"Entity {entityName} does not have a relationship named {relationshipName}",
statusCode: HttpStatusCode.InternalServerError,
subStatusCode: DataApiBuilderException.SubStatusCodes.RelationshipNotFound);
}
}
}
}