diff --git a/src/Cli.Tests/ModuleInitializer.cs b/src/Cli.Tests/ModuleInitializer.cs index bcee0a6993..73f31a3158 100644 --- a/src/Cli.Tests/ModuleInitializer.cs +++ b/src/Cli.Tests/ModuleInitializer.cs @@ -31,6 +31,8 @@ public static void Init() VerifierSettings.IgnoreMember(options => options.IsCachingEnabled); // Ignore the entity IsCachingEnabled as that's unimportant from a test standpoint. VerifierSettings.IgnoreMember(entity => entity.IsCachingEnabled); + // Ignore the entity IsLinkingEntity as that's unimportant from a test standpoint. + VerifierSettings.IgnoreMember(entity => entity.IsLinkingEntity); // Ignore the UserProvidedTtlOptions. They aren't serialized to our config file, enforced by EntityCacheOptionsConverter. VerifierSettings.IgnoreMember(cacheOptions => cacheOptions.UserProvidedTtlOptions); // Ignore the IsRequestBodyStrict as that's unimportant from a test standpoint. diff --git a/src/Config/ObjectModel/Entity.cs b/src/Config/ObjectModel/Entity.cs index e3975f7ee6..ec92a1f5a4 100644 --- a/src/Config/ObjectModel/Entity.cs +++ b/src/Config/ObjectModel/Entity.cs @@ -32,6 +32,9 @@ public record Entity public Dictionary? Relationships { get; init; } public EntityCacheOptions? Cache { get; init; } + [JsonIgnore] + public bool IsLinkingEntity { get; init; } + [JsonConstructor] public Entity( EntitySource Source, @@ -40,7 +43,8 @@ public Entity( EntityPermission[] Permissions, Dictionary? Mappings, Dictionary? Relationships, - EntityCacheOptions? Cache = null) + EntityCacheOptions? Cache = null, + bool IsLinkingEntity = false) { this.Source = Source; this.GraphQL = GraphQL; @@ -49,6 +53,7 @@ public Entity( this.Mappings = Mappings; this.Relationships = Relationships; this.Cache = Cache; + this.IsLinkingEntity = IsLinkingEntity; } /// diff --git a/src/Core/Parsers/EdmModelBuilder.cs b/src/Core/Parsers/EdmModelBuilder.cs index d895c8391b..8180e58db0 100644 --- a/src/Core/Parsers/EdmModelBuilder.cs +++ b/src/Core/Parsers/EdmModelBuilder.cs @@ -47,8 +47,16 @@ private EdmModelBuilder BuildEntityTypes(ISqlMetadataProvider sqlMetadataProvide // since we allow for aliases to be used in place of the names of the actual // columns of the database object (such as table's columns), we need to // account for these potential aliases in our EDM Model. + IReadOnlyDictionary linkingEntities = sqlMetadataProvider.GetLinkingEntities(); foreach (KeyValuePair entityAndDbObject in sqlMetadataProvider.GetEntityNamesAndDbObjects()) { + if (linkingEntities.ContainsKey(entityAndDbObject.Key)) + { + // No need to create entity types for linking entity because the linking entity is not exposed for REST and GraphQL. + // Hence, there is no possibility of having a `filter` operation against it. + continue; + } + // Do not add stored procedures, which do not have table definitions or conventional columns, to edm model // As of now, no ODataFilterParsing will be supported for stored procedure result sets if (entityAndDbObject.Value.SourceType is not EntitySourceType.StoredProcedure) @@ -109,12 +117,19 @@ private EdmModelBuilder BuildEntitySets(ISqlMetadataProvider sqlMetadataProvider // Entity set is a collection of the same entity, if we think of an entity as a row of data // that has a key, then an entity set can be thought of as a table made up of those rows. - foreach (KeyValuePair entityAndDbObject in sqlMetadataProvider.GetEntityNamesAndDbObjects()) + IReadOnlyDictionary linkingEntities = sqlMetadataProvider.GetLinkingEntities(); + foreach ((string entityName, DatabaseObject dbObject) in sqlMetadataProvider.GetEntityNamesAndDbObjects()) { - if (entityAndDbObject.Value.SourceType != EntitySourceType.StoredProcedure) + if (linkingEntities.ContainsKey(entityName)) + { + // No need to create entity set for linking entity. + continue; + } + + if (dbObject.SourceType != EntitySourceType.StoredProcedure) { - string entityName = $"{entityAndDbObject.Value.FullName}"; - container.AddEntitySet(name: $"{entityAndDbObject.Key}.{entityName}", _entities[$"{entityAndDbObject.Key}.{entityName}"]); + string fullSourceName = $"{dbObject.FullName}"; + container.AddEntitySet(name: $"{entityName}.{fullSourceName}", _entities[$"{entityName}.{fullSourceName}"]); } } diff --git a/src/Core/Resolvers/CosmosMutationEngine.cs b/src/Core/Resolvers/CosmosMutationEngine.cs index 1cd4650118..4f95e4f267 100644 --- a/src/Core/Resolvers/CosmosMutationEngine.cs +++ b/src/Core/Resolvers/CosmosMutationEngine.cs @@ -104,7 +104,7 @@ public void AuthorizeMutationFields( List inputArgumentKeys; if (mutationOperation != EntityActionOperation.Delete) { - inputArgumentKeys = BaseSqlQueryStructure.GetSubArgumentNamesFromGQLMutArguments(MutationBuilder.INPUT_ARGUMENT_NAME, parameters); + inputArgumentKeys = BaseSqlQueryStructure.GetSubArgumentNamesFromGQLMutArguments(MutationBuilder.ITEM_INPUT_ARGUMENT_NAME, parameters); } else { @@ -165,7 +165,7 @@ private static async Task> HandleDeleteAsync(IDictionary> HandleCreateAsync(IDictionary queryArgs, Container container) { - object? item = queryArgs[CreateMutationBuilder.INPUT_ARGUMENT_NAME]; + object? item = queryArgs[MutationBuilder.ITEM_INPUT_ARGUMENT_NAME]; JObject? input; // Variables were provided to the mutation @@ -212,7 +212,7 @@ private static async Task> HandleUpdateAsync(IDictionary inputArgumentKeys; if (mutationOperation != EntityActionOperation.Delete) { - inputArgumentKeys = BaseSqlQueryStructure.GetSubArgumentNamesFromGQLMutArguments(MutationBuilder.INPUT_ARGUMENT_NAME, parameters); + inputArgumentKeys = BaseSqlQueryStructure.GetSubArgumentNamesFromGQLMutArguments(MutationBuilder.ITEM_INPUT_ARGUMENT_NAME, parameters); } else { diff --git a/src/Core/Services/GraphQLSchemaCreator.cs b/src/Core/Services/GraphQLSchemaCreator.cs index 9829dbf0f8..dfe1a1b0a6 100644 --- a/src/Core/Services/GraphQLSchemaCreator.cs +++ b/src/Core/Services/GraphQLSchemaCreator.cs @@ -20,6 +20,7 @@ using Azure.DataApiBuilder.Service.Services; using HotChocolate.Language; using Microsoft.Extensions.DependencyInjection; +using static Azure.DataApiBuilder.Service.GraphQLBuilder.GraphQLNaming; namespace Azure.DataApiBuilder.Core.Services { @@ -90,6 +91,7 @@ private ISchemaBuilder Parse( .AddDirectiveType() .AddDirectiveType() .AddDirectiveType() + .AddDirectiveType() .AddDirectiveType() .AddDirectiveType() // Add our custom scalar GraphQL types @@ -162,11 +164,18 @@ public ISchemaBuilder InitializeSchemaAndResolvers(ISchemaBuilder schemaBuilder) /// private DocumentNode GenerateSqlGraphQLObjects(RuntimeEntities entities, Dictionary inputObjects) { + // Dictionary to store: + // 1. Object types for every entity exposed for MySql/PgSql/MsSql/DwSql in the config file. + // 2. Object type for source->target linking object for M:N relationships to support insertion in the target table, + // followed by an insertion in the linking table. The directional linking object contains all the fields from the target entity + // (relationship/column) and non-relationship fields from the linking table. Dictionary objectTypes = new(); - // First pass - build up the object and input types for all the entities + // 1. Build up the object and input types for all the exposed entities in the config. foreach ((string entityName, Entity entity) in entities) { + string dataSourceName = _runtimeConfigProvider.GetConfig().GetDataSourceNameFromEntityName(entityName); + ISqlMetadataProvider sqlMetadataProvider = _metadataProviderFactory.GetMetadataProvider(dataSourceName); // Skip creating the GraphQL object for the current entity due to configuration // explicitly excluding the entity from the GraphQL endpoint. if (!entity.GraphQL.Enabled) @@ -174,9 +183,6 @@ private DocumentNode GenerateSqlGraphQLObjects(RuntimeEntities entities, Diction continue; } - string dataSourceName = _runtimeConfigProvider.GetConfig().GetDataSourceNameFromEntityName(entityName); - ISqlMetadataProvider sqlMetadataProvider = _metadataProviderFactory.GetMetadataProvider(dataSourceName); - if (sqlMetadataProvider.GetEntityNamesAndDbObjects().TryGetValue(entityName, out DatabaseObject? databaseObject)) { // Collection of role names allowed to access entity, to be added to the authorize directive @@ -203,14 +209,14 @@ private DocumentNode GenerateSqlGraphQLObjects(RuntimeEntities entities, Diction // Only add objectTypeDefinition for GraphQL if it has a role definition defined for access. if (rolesAllowedForEntity.Any()) { - ObjectTypeDefinitionNode node = SchemaConverter.FromDatabaseObject( - entityName, - databaseObject, - entity, - entities, - rolesAllowedForEntity, - rolesAllowedForFields - ); + ObjectTypeDefinitionNode node = SchemaConverter.GenerateObjectTypeDefinitionForDatabaseObject( + entityName: entityName, + databaseObject: databaseObject, + configEntity: entity, + entities: entities, + rolesAllowedForEntity: rolesAllowedForEntity, + rolesAllowedForFields: rolesAllowedForFields + ); if (databaseObject.SourceType is not EntitySourceType.StoredProcedure) { @@ -228,12 +234,21 @@ private DocumentNode GenerateSqlGraphQLObjects(RuntimeEntities entities, Diction } } + // For all the fields in the object which hold a foreign key reference to any referenced entity, add a foreign key directive. + AddReferencingFieldDirective(entities, objectTypes); + // Pass two - Add the arguments to the many-to-* relationship fields foreach ((string entityName, ObjectTypeDefinitionNode node) in objectTypes) { objectTypes[entityName] = QueryBuilder.AddQueryArgumentsForRelationships(node, inputObjects); } + // Create ObjectTypeDefinitionNode for linking entities. These object definitions are not exposed in the schema + // but are used to generate the object definitions of directional linking entities for (source, target) and (target, source) entities. + Dictionary linkingObjectTypes = GenerateObjectDefinitionsForLinkingEntities(); + GenerateSourceTargetLinkingObjectDefinitions(objectTypes, linkingObjectTypes); + + // Return a list of all the object types to be exposed in the schema. Dictionary fields = new(); // Add the DBOperationResult type to the schema @@ -254,6 +269,260 @@ private DocumentNode GenerateSqlGraphQLObjects(RuntimeEntities entities, Diction return new DocumentNode(nodes); } + /// + /// Helper method to traverse through all the relationships for all the entities exposed in the config. + /// For all the relationships defined in each entity's configuration, it adds a referencing field directive to all the + /// referencing fields of the referencing entity in the relationship. For relationships defined in config: + /// 1. If an FK constraint exists between the entities - the referencing field directive + /// is added to the referencing fields from the referencing entity. + /// 2. If no FK constraint exists between the entities - the referencing field directive + /// is added to the source.fields/target.fields from both the source and target entities. + /// + /// The values of such fields holding foreign key references can come via insertions in the related entity. + /// By adding ForiegnKeyDirective here, we can later ensure that while creating input type for create mutations, + /// these fields can be marked as nullable/optional. + /// + /// Collection of object types. + /// Entities from runtime config. + private void AddReferencingFieldDirective(RuntimeEntities entities, Dictionary objectTypes) + { + foreach ((string sourceEntityName, ObjectTypeDefinitionNode sourceObjectTypeDefinitionNode) in objectTypes) + { + if (!entities.TryGetValue(sourceEntityName, out Entity? entity)) + { + continue; + } + + if (!entity.GraphQL.Enabled || entity.Source.Type is not EntitySourceType.Table || entity.Relationships is null) + { + // Multiple create is only supported on database tables for which GraphQL endpoint is enabled. + continue; + } + + string dataSourceName = _runtimeConfigProvider.GetConfig().GetDataSourceNameFromEntityName(sourceEntityName); + ISqlMetadataProvider sqlMetadataProvider = _metadataProviderFactory.GetMetadataProvider(dataSourceName); + SourceDefinition sourceDefinition = sqlMetadataProvider.GetSourceDefinition(sourceEntityName); + Dictionary sourceFieldDefinitions = sourceObjectTypeDefinitionNode.Fields.ToDictionary(field => field.Name.Value, field => field); + + // Retrieve all the relationship information for the source entity which is backed by this table definition. + sourceDefinition.SourceEntityRelationshipMap.TryGetValue(sourceEntityName, out RelationshipMetadata? relationshipInfo); + + // Retrieve the database object definition for the source entity. + sqlMetadataProvider.GetEntityNamesAndDbObjects().TryGetValue(sourceEntityName, out DatabaseObject? sourceDbo); + foreach ((_, EntityRelationship relationship) in entity.Relationships) + { + string targetEntityName = relationship.TargetEntity; + if (!string.IsNullOrEmpty(relationship.LinkingObject)) + { + // The presence of LinkingObject indicates that the relationship is a M:N relationship. For M:N relationships, + // the fields in this entity are referenced fields and the fields in the linking table are referencing fields. + // Thus, it is not required to add the directive to any field in this entity. + continue; + } + + // From the relationship information, obtain the foreign key definition for the given target entity and add the + // referencing field directive to the referencing fields from the referencing table (whether it is the source entity or the target entity). + if (relationshipInfo is not null && + relationshipInfo.TargetEntityToFkDefinitionMap.TryGetValue(targetEntityName, out List? listOfForeignKeys)) + { + // Find the foreignkeys in which the source entity is the referencing object. + IEnumerable sourceReferencingForeignKeysInfo = + listOfForeignKeys.Where(fk => + fk.ReferencingColumns.Count > 0 + && fk.ReferencedColumns.Count > 0 + && fk.Pair.ReferencingDbTable.Equals(sourceDbo)); + + sqlMetadataProvider.GetEntityNamesAndDbObjects().TryGetValue(targetEntityName, out DatabaseObject? targetDbo); + // Find the foreignkeys in which the target entity is the referencing object, i.e. source entity is the referenced object. + IEnumerable targetReferencingForeignKeysInfo = + listOfForeignKeys.Where(fk => + fk.ReferencingColumns.Count > 0 + && fk.ReferencedColumns.Count > 0 + && fk.Pair.ReferencingDbTable.Equals(targetDbo)); + + ForeignKeyDefinition? sourceReferencingFKInfo = sourceReferencingForeignKeysInfo.FirstOrDefault(); + if (sourceReferencingFKInfo is not null) + { + // When source entity is the referencing entity, referencing field directive is to be added to relationship fields + // in the source entity. + AddReferencingFieldDirectiveToReferencingFields(sourceFieldDefinitions, sourceReferencingFKInfo.ReferencingColumns, sqlMetadataProvider, sourceEntityName); + } + + ForeignKeyDefinition? targetReferencingFKInfo = targetReferencingForeignKeysInfo.FirstOrDefault(); + if (targetReferencingFKInfo is not null && + objectTypes.TryGetValue(targetEntityName, out ObjectTypeDefinitionNode? targetObjectTypeDefinitionNode)) + { + Dictionary targetFieldDefinitions = targetObjectTypeDefinitionNode.Fields.ToDictionary(field => field.Name.Value, field => field); + // When target entity is the referencing entity, referencing field directive is to be added to relationship fields + // in the target entity. + AddReferencingFieldDirectiveToReferencingFields(targetFieldDefinitions, targetReferencingFKInfo.ReferencingColumns, sqlMetadataProvider, targetEntityName); + + // Update the target object definition with the new set of fields having referencing field directive. + objectTypes[targetEntityName] = targetObjectTypeDefinitionNode.WithFields(new List(targetFieldDefinitions.Values)); + } + } + } + + // Update the source object definition with the new set of fields having referencing field directive. + objectTypes[sourceEntityName] = sourceObjectTypeDefinitionNode.WithFields(new List(sourceFieldDefinitions.Values)); + } + } + + /// + /// Helper method to add referencing field directive type to all the fields in the entity which + /// hold a foreign key reference to another entity exposed in the config, related via a relationship. + /// + /// Field definitions of the referencing entity. + /// Referencing columns in the relationship. + private static void AddReferencingFieldDirectiveToReferencingFields( + Dictionary referencingEntityFieldDefinitions, + List referencingColumns, + ISqlMetadataProvider metadataProvider, + string entityName) + { + foreach (string referencingColumn in referencingColumns) + { + if (metadataProvider.TryGetExposedColumnName(entityName, referencingColumn, out string? exposedReferencingColumnName) && + referencingEntityFieldDefinitions.TryGetValue(exposedReferencingColumnName, out FieldDefinitionNode? referencingFieldDefinition)) + { + if (!referencingFieldDefinition.Directives.Any(directive => directive.Name.Value == ReferencingFieldDirectiveType.DirectiveName)) + { + List directiveNodes = referencingFieldDefinition.Directives.ToList(); + directiveNodes.Add(new DirectiveNode(ReferencingFieldDirectiveType.DirectiveName)); + referencingEntityFieldDefinitions[exposedReferencingColumnName] = referencingFieldDefinition.WithDirectives(directiveNodes); + } + } + } + } + + /// + /// Helper method to generate object definitions for linking entities. These object definitions are used later + /// to generate the object definitions for directional linking entities for (source, target) and (target, source). + /// + /// Object definitions for linking entities. + private Dictionary GenerateObjectDefinitionsForLinkingEntities() + { + IEnumerable sqlMetadataProviders = _metadataProviderFactory.ListMetadataProviders(); + Dictionary linkingObjectTypes = new(); + foreach (ISqlMetadataProvider sqlMetadataProvider in sqlMetadataProviders) + { + foreach ((string linkingEntityName, Entity linkingEntity) in sqlMetadataProvider.GetLinkingEntities()) + { + if (sqlMetadataProvider.GetEntityNamesAndDbObjects().TryGetValue(linkingEntityName, out DatabaseObject? linkingDbObject)) + { + ObjectTypeDefinitionNode node = SchemaConverter.GenerateObjectTypeDefinitionForDatabaseObject( + entityName: linkingEntityName, + databaseObject: linkingDbObject, + configEntity: linkingEntity, + entities: new(new Dictionary()), + rolesAllowedForEntity: new List(), + rolesAllowedForFields: new Dictionary>() + ); + + linkingObjectTypes.Add(linkingEntityName, node); + } + } + } + + return linkingObjectTypes; + } + + /// + /// Helper method to generate object types for linking nodes from (source, target) using + /// simple linking nodes which represent a linking table linking the source and target tables which have an M:N relationship between them. + /// A 'sourceTargetLinkingNode' will contain: + /// 1. All the fields (column/relationship) from the target node, + /// 2. Column fields from the linking node which are not part of the Foreign key constraint (or relationship fields when the relationship + /// is defined in the config). + /// + /// + /// Target node definition contains fields: TField1, TField2, TField3 + /// Linking node definition contains fields: LField1, LField2, LField3 + /// Relationship : linkingTable(Lfield3) -> targetTable(TField3) + /// + /// Result: + /// SourceTargetLinkingNodeDefinition contains fields: + /// 1. TField1, TField2, TField3 (All the fields from the target node.) + /// 2. LField1, LField2 (Non-relationship fields from linking table.) + /// + /// Collection of object types. + /// Collection of object types for linking entities. + private void GenerateSourceTargetLinkingObjectDefinitions( + Dictionary objectTypes, + Dictionary linkingObjectTypes) + { + foreach ((string linkingEntityName, ObjectTypeDefinitionNode linkingObjectDefinition) in linkingObjectTypes) + { + (string sourceEntityName, string targetEntityName) = GraphQLUtils.GetSourceAndTargetEntityNameFromLinkingEntityName(linkingEntityName); + string dataSourceName = _runtimeConfigProvider.GetConfig().GetDataSourceNameFromEntityName(targetEntityName); + ISqlMetadataProvider sqlMetadataProvider = _metadataProviderFactory.GetMetadataProvider(dataSourceName); + if (sqlMetadataProvider.GetEntityNamesAndDbObjects().TryGetValue(sourceEntityName, out DatabaseObject? sourceDbo)) + { + IEnumerable foreignKeyDefinitionsFromSourceToTarget = sourceDbo.SourceDefinition.SourceEntityRelationshipMap[sourceEntityName].TargetEntityToFkDefinitionMap[targetEntityName]; + + // Get list of all referencing columns from the foreign key definition. For an M:N relationship, + // all the referencing columns belong to the linking entity. + HashSet referencingColumnNamesInLinkingEntity = new(foreignKeyDefinitionsFromSourceToTarget.SelectMany(foreignKeyDefinition => foreignKeyDefinition.ReferencingColumns).ToList()); + + // Store the names of relationship/column fields in the target entity to prevent conflicting names + // with the linking table's column fields. + ObjectTypeDefinitionNode targetNode = objectTypes[targetEntityName]; + HashSet fieldNamesInTarget = targetNode.Fields.Select(field => field.Name.Value).ToHashSet(); + + // Initialize list of fields in the sourceTargetLinkingNode with the set of fields present in the target node. + List fieldsInSourceTargetLinkingNode = targetNode.Fields.ToList(); + + // Get list of fields in the linking node (which represents columns present in the linking table). + List fieldsInLinkingNode = linkingObjectDefinition.Fields.ToList(); + + // The sourceTargetLinkingNode will contain: + // 1. All the fields from the target node to perform insertion on the target entity, + // 2. Fields from the linking node which are not a foreign key reference to source or target node. This is needed to perform + // an insertion in the linking table. For the foreign key columns in linking table, the values are derived from the insertions in the + // source and the target table. For the rest of the columns, the value will be provided via a field exposed in the sourceTargetLinkingNode. + foreach (FieldDefinitionNode fieldInLinkingNode in fieldsInLinkingNode) + { + string fieldName = fieldInLinkingNode.Name.Value; + if (!referencingColumnNamesInLinkingEntity.Contains(fieldName)) + { + if (fieldNamesInTarget.Contains(fieldName)) + { + // The fieldName can represent a column in the targetEntity or a relationship. + // The fieldName in the linking node cannot conflict with any of the + // existing field names (either column name or relationship name) in the target node. + bool doesFieldRepresentAColumn = sqlMetadataProvider.TryGetBackingColumn(targetEntityName, fieldName, out string? _); + string infoMsg = $"Cannot use field name '{fieldName}' as it conflicts with another field's name in the entity: {targetEntityName}. "; + string actionableMsg = doesFieldRepresentAColumn ? + $"Consider using the 'mappings' section of the {targetEntityName} entity configuration to provide some other name for the field: '{fieldName}'." : + $"Consider using the 'relationships' section of the {targetEntityName} entity configuration to provide some other name for the relationship: '{fieldName}'."; + throw new DataApiBuilderException( + message: infoMsg + actionableMsg, + statusCode: HttpStatusCode.ServiceUnavailable, + subStatusCode: DataApiBuilderException.SubStatusCodes.ErrorInInitialization); + } + else + { + fieldsInSourceTargetLinkingNode.Add(fieldInLinkingNode); + } + } + } + + // Store object type of the linking node for (sourceEntityName, targetEntityName). + NameNode sourceTargetLinkingNodeName = new(GenerateLinkingNodeName( + objectTypes[sourceEntityName].Name.Value, + targetNode.Name.Value)); + objectTypes.TryAdd(sourceTargetLinkingNodeName.Value, + new( + location: null, + name: sourceTargetLinkingNodeName, + description: null, + new List() { }, + new List(), + fieldsInSourceTargetLinkingNode)); + } + } + } + /// /// Generates the ObjectTypeDefinitionNodes and InputObjectTypeDefinitionNodes as part of GraphQL Schema generation for cosmos db. /// Each datasource in cosmos has a root file provided which is used to generate the schema. diff --git a/src/Core/Services/MetadataProviders/ISqlMetadataProvider.cs b/src/Core/Services/MetadataProviders/ISqlMetadataProvider.cs index c79958e1d9..8b92e5a3a6 100644 --- a/src/Core/Services/MetadataProviders/ISqlMetadataProvider.cs +++ b/src/Core/Services/MetadataProviders/ISqlMetadataProvider.cs @@ -53,6 +53,11 @@ bool VerifyForeignKeyExistsInDB( /// FieldDefinitionNode? GetSchemaGraphQLFieldFromFieldName(string entityName, string fieldName); + /// + /// Gets a collection of linking entities generated by DAB (required to support multiple mutations). + /// + IReadOnlyDictionary GetLinkingEntities() => new Dictionary(); + /// /// Obtains the underlying SourceDefinition for the given entity name. /// diff --git a/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs b/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs index 3bc4e876e1..60e8543512 100644 --- a/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs +++ b/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs @@ -13,6 +13,7 @@ using Azure.DataApiBuilder.Core.Resolvers; using Azure.DataApiBuilder.Core.Resolvers.Factories; using Azure.DataApiBuilder.Service.Exceptions; +using Azure.DataApiBuilder.Service.GraphQLBuilder; using Microsoft.Data.SqlClient; using Microsoft.Extensions.Logging; using static Azure.DataApiBuilder.Service.GraphQLBuilder.GraphQLNaming; @@ -211,6 +212,39 @@ protected override async Task FillSchemaForStoredProcedureAsync( GraphQLStoredProcedureExposedNameToEntityNameMap.TryAdd(GenerateStoredProcedureGraphQLFieldName(entityName, procedureEntity), entityName); } + /// + protected override void PopulateMetadataForLinkingObject( + string entityName, + string targetEntityName, + string linkingObject, + Dictionary sourceObjects) + { + if (!GraphQLUtils.DoesRelationalDBSupportMultipleCreate(GetDatabaseType())) + { + // Currently we have this same class instantiated for both MsSql and DwSql. + // This is a refactor we need to take care of in future. + return; + } + + string linkingEntityName = GraphQLUtils.GenerateLinkingEntityName(entityName, targetEntityName); + + // Create linking entity with disabled REST/GraphQL endpoints. + // Even though GraphQL endpoint is disabled, we will be able to later create an object type definition + // for this linking entity (which is later used to generate source->target linking object definition) + // because the logic for creation of object definition for linking entity does not depend on whether + // GraphQL is enabled/disabled. The linking object definitions are not exposed in the schema to the user. + Entity linkingEntity = new( + Source: new EntitySource(Type: EntitySourceType.Table, Object: linkingObject, Parameters: null, KeyFields: null), + Rest: new(Array.Empty(), Enabled: false), + GraphQL: new(Singular: linkingEntityName, Plural: linkingEntityName, Enabled: false), + Permissions: Array.Empty(), + Relationships: null, + Mappings: new(), + IsLinkingEntity: true); + _linkingEntities.TryAdd(linkingEntityName, linkingEntity); + PopulateDatabaseObjectForEntity(linkingEntity, linkingEntityName, sourceObjects); + } + /// /// Takes a string version of a sql date/time type and returns its corresponding DbType. /// diff --git a/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs b/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs index 65027306f2..144f7c0df9 100644 --- a/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs +++ b/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs @@ -37,7 +37,11 @@ public abstract class SqlMetadataProvider : private readonly DatabaseType _databaseType; - private readonly IReadOnlyDictionary _entities; + // Represents the entities exposed in the runtime config. + private IReadOnlyDictionary _entities; + + // Represents the linking entities created by DAB to support multiple mutations for entities having an M:N relationship between them. + protected Dictionary _linkingEntities = new(); protected readonly string _dataSourceName; @@ -599,70 +603,76 @@ protected virtual Dictionary /// private void GenerateDatabaseObjectForEntities() { - string schemaName, dbObjectName; Dictionary sourceObjects = new(); foreach ((string entityName, Entity entity) in _entities) { - try - { - EntitySourceType sourceType = GetEntitySourceType(entityName, entity); + PopulateDatabaseObjectForEntity(entity, entityName, sourceObjects); + } + } - if (!EntityToDatabaseObject.ContainsKey(entityName)) + protected void PopulateDatabaseObjectForEntity( + Entity entity, + string entityName, + Dictionary sourceObjects) + { + try + { + EntitySourceType sourceType = GetEntitySourceType(entityName, entity); + if (!EntityToDatabaseObject.ContainsKey(entityName)) + { + // Reuse the same Database object for multiple entities if they share the same source. + if (!sourceObjects.TryGetValue(entity.Source.Object, out DatabaseObject? sourceObject)) { - // Reuse the same Database object for multiple entities if they share the same source. - if (!sourceObjects.TryGetValue(entity.Source.Object, out DatabaseObject? sourceObject)) - { - // parse source name into a tuple of (schemaName, databaseObjectName) - (schemaName, dbObjectName) = ParseSchemaAndDbTableName(entity.Source.Object)!; + // parse source name into a tuple of (schemaName, databaseObjectName) + (string schemaName, string dbObjectName) = ParseSchemaAndDbTableName(entity.Source.Object)!; - // if specified as stored procedure in config, - // initialize DatabaseObject as DatabaseStoredProcedure, - // else with DatabaseTable (for tables) / DatabaseView (for views). + // if specified as stored procedure in config, + // initialize DatabaseObject as DatabaseStoredProcedure, + // else with DatabaseTable (for tables) / DatabaseView (for views). - if (sourceType is EntitySourceType.StoredProcedure) + if (sourceType is EntitySourceType.StoredProcedure) + { + sourceObject = new DatabaseStoredProcedure(schemaName, dbObjectName) { - sourceObject = new DatabaseStoredProcedure(schemaName, dbObjectName) - { - SourceType = sourceType, - StoredProcedureDefinition = new() - }; - } - else if (sourceType is EntitySourceType.Table) + SourceType = sourceType, + StoredProcedureDefinition = new() + }; + } + else if (sourceType is EntitySourceType.Table) + { + sourceObject = new DatabaseTable() { - sourceObject = new DatabaseTable() - { - SchemaName = schemaName, - Name = dbObjectName, - SourceType = sourceType, - TableDefinition = new() - }; - } - else + SchemaName = schemaName, + Name = dbObjectName, + SourceType = sourceType, + TableDefinition = new() + }; + } + else + { + sourceObject = new DatabaseView(schemaName, dbObjectName) { - sourceObject = new DatabaseView(schemaName, dbObjectName) - { - SchemaName = schemaName, - Name = dbObjectName, - SourceType = sourceType, - ViewDefinition = new() - }; - } - - sourceObjects.Add(entity.Source.Object, sourceObject); + SchemaName = schemaName, + Name = dbObjectName, + SourceType = sourceType, + ViewDefinition = new() + }; } - EntityToDatabaseObject.Add(entityName, sourceObject); + sourceObjects.Add(entity.Source.Object, sourceObject); + } - if (entity.Relationships is not null && entity.Source.Type is EntitySourceType.Table) - { - AddForeignKeysForRelationships(entityName, entity, (DatabaseTable)sourceObject); - } + EntityToDatabaseObject.Add(entityName, sourceObject); + + if (entity.Relationships is not null && entity.Source.Type is EntitySourceType.Table) + { + ProcessRelationships(entityName, entity, (DatabaseTable)sourceObject, sourceObjects); } } - catch (Exception e) - { - HandleOrRecordException(e); - } + } + catch (Exception e) + { + HandleOrRecordException(e); } } @@ -698,10 +708,11 @@ private static EntitySourceType GetEntitySourceType(string entityName, Entity en /// /// /// - private void AddForeignKeysForRelationships( + private void ProcessRelationships( string entityName, Entity entity, - DatabaseTable databaseTable) + DatabaseTable databaseTable, + Dictionary sourceObjects) { SourceDefinition sourceDefinition = GetSourceDefinition(entityName); if (!sourceDefinition.SourceEntityRelationshipMap @@ -743,6 +754,18 @@ private void AddForeignKeysForRelationships( referencingColumns: relationship.LinkingTargetFields, referencedColumns: relationship.TargetFields, relationshipData); + + // When a linking object is encountered for a database table, we will create a linking entity for the object. + // Subsequently, we will also populate the Database object for the linking entity. This is used to infer + // metadata about linking object needed to create GQL schema for multiple insertions. + if (entity.Source.Type is EntitySourceType.Table) + { + PopulateMetadataForLinkingObject( + entityName: entityName, + targetEntityName: targetEntityName, + linkingObject: relationship.LinkingObject, + sourceObjects: sourceObjects); + } } else if (relationship.Cardinality == Cardinality.One) { @@ -800,6 +823,24 @@ private void AddForeignKeysForRelationships( } } + /// + /// Helper method to create a linking entity and a database object for the given linking object (which relates the source and target with an M:N relationship). + /// The created linking entity and its corresponding database object definition is later used during GraphQL schema generation + /// to enable multiple mutations. + /// + /// Source entity name. + /// Target entity name. + /// Linking object + /// Dictionary storing a collection of database objects which have been created. + protected virtual void PopulateMetadataForLinkingObject( + string entityName, + string targetEntityName, + string linkingObject, + Dictionary sourceObjects) + { + return; + } + /// /// Adds a new foreign key definition for the target entity /// in the relationship metadata. @@ -897,6 +938,11 @@ public List GetSchemaGraphQLFieldNamesForEntityName(string entityName) public FieldDefinitionNode? GetSchemaGraphQLFieldFromFieldName(string graphQLType, string fieldName) => throw new NotImplementedException(); + public IReadOnlyDictionary GetLinkingEntities() + { + return _linkingEntities; + } + /// /// Enrich the entities in the runtime config with the /// object definition information needed by the runtime to serve requests. @@ -907,53 +953,63 @@ private async Task PopulateObjectDefinitionForEntities() { foreach ((string entityName, Entity entity) in _entities) { - try + await PopulateObjectDefinitionForEntity(entityName, entity); + } + + foreach ((string entityName, Entity entity) in _linkingEntities) + { + await PopulateObjectDefinitionForEntity(entityName, entity); + } + + await PopulateForeignKeyDefinitionAsync(); + } + + private async Task PopulateObjectDefinitionForEntity(string entityName, Entity entity) + { + try + { + EntitySourceType entitySourceType = GetEntitySourceType(entityName, entity); + if (entitySourceType is EntitySourceType.StoredProcedure) { - EntitySourceType entitySourceType = GetEntitySourceType(entityName, entity); - if (entitySourceType is EntitySourceType.StoredProcedure) + await FillSchemaForStoredProcedureAsync( + entity, + entityName, + GetSchemaName(entityName), + GetDatabaseObjectName(entityName), + GetStoredProcedureDefinition(entityName)); + + if (GetDatabaseType() == DatabaseType.MSSQL || GetDatabaseType() == DatabaseType.DWSQL) { - await FillSchemaForStoredProcedureAsync( - entity, - entityName, + await PopulateResultSetDefinitionsForStoredProcedureAsync( GetSchemaName(entityName), GetDatabaseObjectName(entityName), GetStoredProcedureDefinition(entityName)); - - if (GetDatabaseType() == DatabaseType.MSSQL || GetDatabaseType() == DatabaseType.DWSQL) - { - await PopulateResultSetDefinitionsForStoredProcedureAsync( - GetSchemaName(entityName), - GetDatabaseObjectName(entityName), - GetStoredProcedureDefinition(entityName)); - } - } - else if (entitySourceType is EntitySourceType.Table) - { - await PopulateSourceDefinitionAsync( - entityName, - GetSchemaName(entityName), - GetDatabaseObjectName(entityName), - GetSourceDefinition(entityName), - entity.Source.KeyFields); - } - else - { - ViewDefinition viewDefinition = (ViewDefinition)GetSourceDefinition(entityName); - await PopulateSourceDefinitionAsync( - entityName, - GetSchemaName(entityName), - GetDatabaseObjectName(entityName), - viewDefinition, - entity.Source.KeyFields); } } - catch (Exception e) + else if (entitySourceType is EntitySourceType.Table) { - HandleOrRecordException(e); + await PopulateSourceDefinitionAsync( + entityName, + GetSchemaName(entityName), + GetDatabaseObjectName(entityName), + GetSourceDefinition(entityName), + entity.Source.KeyFields); + } + else + { + ViewDefinition viewDefinition = (ViewDefinition)GetSourceDefinition(entityName); + await PopulateSourceDefinitionAsync( + entityName, + GetSchemaName(entityName), + GetDatabaseObjectName(entityName), + viewDefinition, + entity.Source.KeyFields); } } - - await PopulateForeignKeyDefinitionAsync(); + catch (Exception e) + { + HandleOrRecordException(e); + } } /// diff --git a/src/Core/Services/OpenAPI/OpenApiDocumentor.cs b/src/Core/Services/OpenAPI/OpenApiDocumentor.cs index f482076f88..ebe09223e4 100644 --- a/src/Core/Services/OpenAPI/OpenApiDocumentor.cs +++ b/src/Core/Services/OpenAPI/OpenApiDocumentor.cs @@ -178,6 +178,12 @@ private OpenApiPaths BuildPaths() foreach (KeyValuePair entityDbMetadataMap in metadataProvider.EntityToDatabaseObject) { string entityName = entityDbMetadataMap.Key; + if (!_runtimeConfig.Entities.ContainsKey(entityName)) + { + // This can happen for linking entities which are not present in runtime config. + continue; + } + string entityRestPath = GetEntityRestPath(entityName); string entityBasePathComponent = $"/{entityRestPath}"; @@ -962,12 +968,12 @@ private Dictionary CreateComponentSchemas() string entityName = entityDbMetadataMap.Key; DatabaseObject dbObject = entityDbMetadataMap.Value; - if (_runtimeConfig.Entities.TryGetValue(entityName, out Entity? entity) && entity is not null) + if (!_runtimeConfig.Entities.TryGetValue(entityName, out Entity? entity) || !entity.Rest.Enabled) { - if (!entity.Rest.Enabled) - { - continue; - } + // Don't create component schemas for: + // 1. Linking entity: The entity will be null when we are dealing with a linking entity, which is not exposed in the config. + // 2. Entity for which REST endpoint is disabled. + continue; } SourceDefinition sourceDefinition = metadataProvider.GetSourceDefinition(entityName); diff --git a/src/Core/Services/RequestValidator.cs b/src/Core/Services/RequestValidator.cs index 7519394f64..fff83c922f 100644 --- a/src/Core/Services/RequestValidator.cs +++ b/src/Core/Services/RequestValidator.cs @@ -485,8 +485,9 @@ public void ValidateEntity(string entityName) { ISqlMetadataProvider sqlMetadataProvider = GetSqlMetadataProvider(entityName); IEnumerable entities = sqlMetadataProvider.EntityToDatabaseObject.Keys; - if (!entities.Contains(entityName)) + if (!entities.Contains(entityName) || sqlMetadataProvider.GetLinkingEntities().ContainsKey(entityName)) { + // Do not validate the entity if the entity definition does not exist or if the entity is a linking entity. throw new DataApiBuilderException( message: $"{entityName} is not a valid entity.", statusCode: HttpStatusCode.NotFound, diff --git a/src/Service.GraphQLBuilder/Directives/ReferencingFieldDirectiveType.cs b/src/Service.GraphQLBuilder/Directives/ReferencingFieldDirectiveType.cs new file mode 100644 index 0000000000..460e7b14e2 --- /dev/null +++ b/src/Service.GraphQLBuilder/Directives/ReferencingFieldDirectiveType.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using HotChocolate.Types; + +namespace Azure.DataApiBuilder.Service.GraphQLBuilder.Directives +{ + public class ReferencingFieldDirectiveType : DirectiveType + { + public static string DirectiveName { get; } = "dab_referencingField"; + + protected override void Configure(IDirectiveTypeDescriptor descriptor) + { + descriptor + .Name(DirectiveName) + .Description("When present on a field in a database table, indicates that the field is a referencing field " + + "to some field in the same/another database table.") + .Location(DirectiveLocation.FieldDefinition); + } + } +} diff --git a/src/Service.GraphQLBuilder/GraphQLNaming.cs b/src/Service.GraphQLBuilder/GraphQLNaming.cs index 101b537d09..3e31ee08bb 100644 --- a/src/Service.GraphQLBuilder/GraphQLNaming.cs +++ b/src/Service.GraphQLBuilder/GraphQLNaming.cs @@ -28,6 +28,8 @@ public static class GraphQLNaming /// public const string INTROSPECTION_FIELD_PREFIX = "__"; + public const string LINKING_OBJECT_PREFIX = "linkingObject"; + /// /// Enforces the GraphQL naming restrictions on . /// Completely removes invalid characters from the input parameter: name. @@ -92,7 +94,7 @@ public static bool IsIntrospectionField(string fieldName) /// /// Attempts to deserialize and get the SingularPlural GraphQL naming config - /// of an Entity from the Runtime Configuration. + /// of an Entity from the Runtime Configuration and return the singular name of the entity. /// public static string GetDefinedSingularName(string entityName, Entity configEntity) { @@ -104,6 +106,20 @@ public static string GetDefinedSingularName(string entityName, Entity configEnti return configEntity.GraphQL.Singular; } + /// + /// Attempts to deserialize and get the SingularPlural GraphQL naming config + /// of an Entity from the Runtime Configuration and return the plural name of the entity. + /// + public static string GetDefinedPluralName(string entityName, Entity configEntity) + { + if (string.IsNullOrEmpty(configEntity.GraphQL.Plural)) + { + throw new ArgumentException($"The entity '{entityName}' does not have a plural name defined in config, nor has one been extrapolated from the entity name."); + } + + return configEntity.GraphQL.Plural; + } + /// /// Format fields generated by the runtime aligning with /// GraphQL best practices. @@ -185,5 +201,14 @@ public static string GenerateStoredProcedureGraphQLFieldName(string entityName, string preformattedField = $"execute{GetDefinedSingularName(entityName, entity)}"; return FormatNameForField(preformattedField); } + + /// + /// Helper method to generate the linking node name from source to target entities having a relationship + /// with cardinality M:N between them. + /// + public static string GenerateLinkingNodeName(string sourceNodeName, string targetNodeName) + { + return LINKING_OBJECT_PREFIX + sourceNodeName + targetNodeName; + } } } diff --git a/src/Service.GraphQLBuilder/GraphQLUtils.cs b/src/Service.GraphQLBuilder/GraphQLUtils.cs index 7128b2049b..78ad23d925 100644 --- a/src/Service.GraphQLBuilder/GraphQLUtils.cs +++ b/src/Service.GraphQLBuilder/GraphQLUtils.cs @@ -26,6 +26,16 @@ public static class GraphQLUtils 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 RELATIONAL_DBS_SUPPORTING_MULTIPLE_CREATE = new() { DatabaseType.MSSQL }; + + public static HashSet RELATIONAL_DBS = new() { DatabaseType.MSSQL, DatabaseType.MySQL, + DatabaseType.DWSQL, DatabaseType.PostgreSQL, DatabaseType.CosmosDB_PostgreSQL }; + public static bool IsModelType(ObjectTypeDefinitionNode objectTypeDefinitionNode) { string modelDirectiveName = ModelDirectiveType.DirectiveName; @@ -61,6 +71,22 @@ public static bool IsBuiltInType(ITypeNode typeNode) return builtInTypes.Contains(name); } + /// + /// Helper method to evaluate whether DAB supports multiple create for a particular database type. + /// + public static bool DoesRelationalDBSupportMultipleCreate(DatabaseType databaseType) + { + return RELATIONAL_DBS_SUPPORTING_MULTIPLE_CREATE.Contains(databaseType); + } + + /// + /// Helper method to evaluate whether database type represents a NoSQL database. + /// + public static bool IsRelationalDb(DatabaseType databaseType) + { + return RELATIONAL_DBS.Contains(databaseType); + } + /// /// Find all the primary keys for a given object node /// using the information available in the directives. @@ -306,5 +332,39 @@ private static string GenerateDataSourceNameKeyFromPath(IPureResolverContext con { return $"{context.Path.ToList()[0]}"; } + + /// + /// Helper method to generate the linking entity name using the source and target entity names. + /// + /// Source entity name. + /// Target entity name. + /// Name of the linking entity 'LinkingEntity$SourceEntityName$TargetEntityName'. + public static string GenerateLinkingEntityName(string source, string target) + { + return LINKING_ENTITY_PREFIX + ENTITY_NAME_DELIMITER + source + ENTITY_NAME_DELIMITER + target; + } + + /// + /// Helper method to decode the names of source and target entities from the name of a linking entity. + /// + /// linking entity name of the format 'LinkingEntity$SourceEntityName$TargetEntityName'. + /// tuple of source, target entities name of the format (SourceEntityName, TargetEntityName). + /// Thrown when the linking entity name is not of the expected format. + public static Tuple 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]); + } } } diff --git a/src/Service.GraphQLBuilder/Mutations/CreateMutationBuilder.cs b/src/Service.GraphQLBuilder/Mutations/CreateMutationBuilder.cs index ac93a7ffa3..62b35c7e2e 100644 --- a/src/Service.GraphQLBuilder/Mutations/CreateMutationBuilder.cs +++ b/src/Service.GraphQLBuilder/Mutations/CreateMutationBuilder.cs @@ -15,22 +15,28 @@ namespace Azure.DataApiBuilder.Service.GraphQLBuilder.Mutations { public static class CreateMutationBuilder { + private const string CREATE_MULTIPLE_MUTATION_SUFFIX = "Multiple"; public const string INPUT_ARGUMENT_NAME = "item"; + public const string CREATE_MUTATION_PREFIX = "create"; /// - /// Generate the GraphQL input type from an object type + /// Generate the GraphQL input type from an object type for relational database. /// /// Reference table of all known input types. /// GraphQL object to generate the input type for. /// Name of the GraphQL object type. + /// In case when we are creating input type for linking object, baseEntityName is equal to the targetEntityName, + /// else baseEntityName is equal to the name parameter. /// All named GraphQL items in the schema (objects, enums, scalars, etc.) - /// Database type to generate input type for. + /// Database type of the relational database to generate input type for. /// Runtime config information. /// A GraphQL input type with all expected fields mapped as GraphQL inputs. - private static InputObjectTypeDefinitionNode GenerateCreateInputType( + private static InputObjectTypeDefinitionNode GenerateCreateInputTypeForRelationalDb( Dictionary inputs, ObjectTypeDefinitionNode objectTypeDefinitionNode, + string entityName, NameNode name, + NameNode baseEntityName, IEnumerable definitions, DatabaseType databaseType, RuntimeEntities entities) @@ -42,31 +48,166 @@ private static InputObjectTypeDefinitionNode GenerateCreateInputType( return inputs[inputName]; } - IEnumerable inputFields = - objectTypeDefinitionNode.Fields - .Where(f => FieldAllowedOnCreateInput(f, databaseType, definitions)) - .Select(f => + // The input fields for a create object will be a combination of: + // 1. Scalar input fields corresponding to columns which belong to the table. + // 2. Complex input fields corresponding to related (target) entities (table backed entities, for now) + // which are defined in the runtime config. + List inputFields = new(); + + // 1. Scalar input fields. + IEnumerable scalarInputFields = objectTypeDefinitionNode.Fields + .Where(field => IsBuiltInType(field.Type) && !IsAutoGeneratedField(field)) + .Select(field => { - if (!IsBuiltInType(f.Type)) + return GenerateScalarInputType(name, field, databaseType); + }); + + // Add scalar input fields to list of input fields for current input type. + inputFields.AddRange(scalarInputFields); + + // Create input object for this entity. + InputObjectTypeDefinitionNode input = + new( + location: null, + inputName, + new StringValueNode($"Input type for creating {name}"), + new List(), + inputFields + ); + + // Add input object to the dictionary of entities for which input object has already been created. + // This input object currently holds only scalar fields. + // The complex fields (for related entities) would be added later when we return from recursion. + // Adding the input object to the dictionary ensures that we don't go into infinite recursion and return whenever + // we find that the input object has already been created for the entity. + inputs.Add(input.Name, input); + + // Generate fields for related entities only if multiple mutations are supported for the database flavor. + if (DoesRelationalDBSupportMultipleCreate(databaseType)) + { + // 2. Complex input fields. + // Evaluate input objects for related entities. + IEnumerable complexInputFields = + objectTypeDefinitionNode.Fields + .Where(field => !IsBuiltInType(field.Type) && IsComplexFieldAllowedForCreateInputInRelationalDb(field, databaseType, definitions)) + .Select(field => { - string typeName = RelationshipDirectiveType.Target(f); - HotChocolate.Language.IHasName? def = definitions.FirstOrDefault(d => d.Name.Value == typeName); + string typeName = RelationshipDirectiveType.Target(field); + HotChocolate.Language.IHasName? def = definitions.FirstOrDefault(d => d.Name.Value.Equals(typeName)); if (def is null) { - throw new DataApiBuilderException($"The type {typeName} is not a known GraphQL type, and cannot be used in this schema.", HttpStatusCode.InternalServerError, DataApiBuilderException.SubStatusCodes.GraphQLMapping); + throw new DataApiBuilderException( + message: $"The type {typeName} is not a known GraphQL type, and cannot be used in this schema.", + statusCode: HttpStatusCode.ServiceUnavailable, + subStatusCode: DataApiBuilderException.SubStatusCodes.ErrorInInitialization); } - if (def is ObjectTypeDefinitionNode otdn) + if (!entities.TryGetValue(entityName, out Entity? entity) || entity.Relationships is null) { - //Get entity definition for this ObjectTypeDefinitionNode - return GetComplexInputType(inputs, definitions, f, typeName, otdn, databaseType, entities); + throw new DataApiBuilderException( + message: $"Could not find entity metadata for entity: {entityName}.", + statusCode: HttpStatusCode.ServiceUnavailable, + subStatusCode: DataApiBuilderException.SubStatusCodes.ErrorInInitialization); } + + string targetEntityName = entity.Relationships[field.Name.Value].TargetEntity; + if (IsMToNRelationship(entity, field.Name.Value)) + { + // The field can represent a related entity with M:N relationship with the parent. + NameNode baseObjectTypeNameForField = new(typeName); + typeName = GenerateLinkingNodeName(baseEntityName.Value, typeName); + def = (ObjectTypeDefinitionNode)definitions.FirstOrDefault(d => d.Name.Value.Equals(typeName))!; + + // Get entity definition for this ObjectTypeDefinitionNode. + // Recurse for evaluating input objects for related entities. + return GenerateComplexInputTypeForRelationalDb( + entityName: targetEntityName, + inputs: inputs, + definitions: definitions, + field: field, + typeName: typeName, + targetObjectTypeName: baseObjectTypeNameForField, + objectTypeDefinitionNode: (ObjectTypeDefinitionNode)def, + databaseType: databaseType, + entities: entities); + } + + // Get entity definition for this ObjectTypeDefinitionNode. + // Recurse for evaluating input objects for related entities. + return GenerateComplexInputTypeForRelationalDb( + entityName: targetEntityName, + inputs: inputs, + definitions: definitions, + field: field, + typeName: typeName, + targetObjectTypeName: new(typeName), + objectTypeDefinitionNode: (ObjectTypeDefinitionNode)def, + databaseType: databaseType, + entities: entities); + }); + // Append relationship fields to the input fields. + inputFields.AddRange(complexInputFields); + } + + return input; + } + + /// + /// Generate the GraphQL input type from an object type for non-relational database. + /// + /// Reference table of all known input types. + /// GraphQL object to generate the input type for. + /// Name of the GraphQL object type. + /// All named GraphQL items in the schema (objects, enums, scalars, etc.) + /// Database type of the non-relational database to generate input type for. + /// Runtime config information. + /// A GraphQL input type with all expected fields mapped as GraphQL inputs. + private static InputObjectTypeDefinitionNode GenerateCreateInputTypeForNonRelationalDb( + Dictionary inputs, + ObjectTypeDefinitionNode objectTypeDefinitionNode, + NameNode name, + IEnumerable definitions, + DatabaseType databaseType) + { + NameNode inputName = GenerateInputTypeName(name.Value); + + if (inputs.ContainsKey(inputName)) + { + return inputs[inputName]; + } + + IEnumerable inputFields = + objectTypeDefinitionNode.Fields + .Select(field => + { + if (IsBuiltInType(field.Type)) + { + return GenerateScalarInputType(name, field, databaseType); } - return GenerateSimpleInputType(name, f); + string typeName = RelationshipDirectiveType.Target(field); + HotChocolate.Language.IHasName? def = definitions.FirstOrDefault(d => d.Name.Value.Equals(typeName)); + + if (def is null) + { + throw new DataApiBuilderException( + message: $"The type {typeName} is not a known GraphQL type, and cannot be used in this schema.", + statusCode: HttpStatusCode.ServiceUnavailable, + subStatusCode: DataApiBuilderException.SubStatusCodes.ErrorInInitialization); + } + + //Get entity definition for this ObjectTypeDefinitionNode + return GenerateComplexInputTypeForNonRelationalDb( + inputs: inputs, + definitions: definitions, + field: field, + typeName: typeName, + objectTypeDefinitionNode: (ObjectTypeDefinitionNode)def, + databaseType: databaseType); }); + // Create input object for this entity. InputObjectTypeDefinitionNode input = new( location: null, @@ -81,78 +222,90 @@ private static InputObjectTypeDefinitionNode GenerateCreateInputType( } /// - /// This method is used to determine if a field is allowed to be sent from the client in a Create mutation (eg, id field is not settable during create). + /// This method is used to determine if a relationship field is allowed to be sent from the client in a Create mutation + /// for a relational database. If the field is a pagination field (for *:N relationships) or if we infer an object + /// definition for the field (for *:1 relationships), the field is allowed in the create input. /// /// Field to check /// The type of database to generate for /// The other named types in the schema /// true if the field is allowed, false if it is not. - private static bool FieldAllowedOnCreateInput(FieldDefinitionNode field, DatabaseType databaseType, IEnumerable definitions) + private static bool IsComplexFieldAllowedForCreateInputInRelationalDb(FieldDefinitionNode field, DatabaseType databaseType, IEnumerable definitions) { - if (IsBuiltInType(field.Type)) - { - // cosmosdb_nosql doesn't have the concept of "auto increment" for the ID field, nor does it have "auto generate" - // fields like timestap/etc. like SQL, so we're assuming that any built-in type will be user-settable - // during the create mutation - return databaseType switch - { - DatabaseType.CosmosDB_NoSQL => true, - _ => !IsAutoGeneratedField(field), - }; - } - if (QueryBuilder.IsPaginationType(field.Type.NamedType())) { - return false; + return DoesRelationalDBSupportMultipleCreate(databaseType); } HotChocolate.Language.IHasName? definition = definitions.FirstOrDefault(d => d.Name.Value == field.Type.NamedType().Name.Value); - // When creating, you don't need to provide the data for nested models, but you will for other nested types - // For cosmos, allow updating nested objects - if (definition != null && definition is ObjectTypeDefinitionNode objectType && IsModelType(objectType) && databaseType is not DatabaseType.CosmosDB_NoSQL) + if (definition != null && definition is ObjectTypeDefinitionNode objectType && IsModelType(objectType)) { - return false; + return DoesRelationalDBSupportMultipleCreate(databaseType); } - return true; + return false; } - private static InputValueDefinitionNode GenerateSimpleInputType(NameNode name, FieldDefinitionNode f) + /// + /// Helper method to check if a field in an entity(table) is a referencing field to a referenced field + /// in another entity. + /// + /// Field definition. + private static bool DoesFieldHaveReferencingFieldDirective(FieldDefinitionNode field) + { + return field.Directives.Any(d => d.Name.Value.Equals(ReferencingFieldDirectiveType.DirectiveName)); + } + + /// + /// Helper method to create input type for a scalar/column field in an entity. + /// + /// Name of the field. + /// Field definition. + /// Database type + private static InputValueDefinitionNode GenerateScalarInputType(NameNode name, FieldDefinitionNode fieldDefinition, DatabaseType databaseType) { IValueNode? defaultValue = null; - if (DefaultValueDirectiveType.TryGetDefaultValue(f, out ObjectValueNode? value)) + if (DefaultValueDirectiveType.TryGetDefaultValue(fieldDefinition, out ObjectValueNode? value)) { defaultValue = value.Fields[0].Value; } + bool isFieldNullable = defaultValue is not null || + (IsRelationalDb(databaseType) && DoesRelationalDBSupportMultipleCreate(databaseType) && DoesFieldHaveReferencingFieldDirective(fieldDefinition)); + return new( location: null, - f.Name, - new StringValueNode($"Input for field {f.Name} on type {GenerateInputTypeName(name.Value)}"), - defaultValue is not null ? f.Type.NullableType() : f.Type, + fieldDefinition.Name, + new StringValueNode($"Input for field {fieldDefinition.Name} on type {GenerateInputTypeName(name.Value)}"), + isFieldNullable ? fieldDefinition.Type.NullableType() : fieldDefinition.Type, defaultValue, new List() ); } /// - /// Generates a GraphQL Input Type value for an object type, generally one provided from the database. + /// Generates a GraphQL Input Type value for: + /// 1. An object type sourced from the relational database (for entities exposed in config), + /// 2. For source->target linking object types needed to support multiple create. /// /// Dictionary of all input types, allowing reuse where possible. /// All named GraphQL types from the schema (objects, enums, etc.) for referencing. /// Field that the input type is being generated for. - /// Name of the input type in the dictionary. - /// The GraphQL object type to create the input type for. + /// In case of relationships with M:N cardinality, typeName = type name of linking object, else typeName = type name of target entity. + /// Object type name of the target entity. + /// The GraphQL object type to create the input type for. /// Database type to generate the input type for. /// Runtime configuration information for entities. /// A GraphQL input type value. - private static InputValueDefinitionNode GetComplexInputType( + private static InputValueDefinitionNode GenerateComplexInputTypeForRelationalDb( + string entityName, Dictionary inputs, IEnumerable definitions, FieldDefinitionNode field, string typeName, - ObjectTypeDefinitionNode otdn, + NameNode targetObjectTypeName, + ObjectTypeDefinitionNode objectTypeDefinitionNode, DatabaseType databaseType, RuntimeEntities entities) { @@ -160,20 +313,93 @@ private static InputValueDefinitionNode GetComplexInputType( NameNode inputTypeName = GenerateInputTypeName(typeName); if (!inputs.ContainsKey(inputTypeName)) { - node = GenerateCreateInputType(inputs, otdn, field.Type.NamedType().Name, definitions, databaseType, entities); + node = GenerateCreateInputTypeForRelationalDb( + inputs, + objectTypeDefinitionNode, + entityName, + new NameNode(typeName), + targetObjectTypeName, + definitions, + databaseType, + entities); + } + else + { + node = inputs[inputTypeName]; + } + + return GetComplexInputType(field, databaseType, node, inputTypeName); + } + + /// + /// Generates a GraphQL Input Type value for an object type, provided from the non-relational database. + /// + /// Dictionary of all input types, allowing reuse where possible. + /// All named GraphQL types from the schema (objects, enums, etc.) for referencing. + /// Field that the input type is being generated for. + /// Type name of the related entity. + /// The GraphQL object type to create the input type for. + /// Database type to generate the input type for. + /// A GraphQL input type value. + private static InputValueDefinitionNode GenerateComplexInputTypeForNonRelationalDb( + Dictionary inputs, + IEnumerable definitions, + FieldDefinitionNode field, + string typeName, + ObjectTypeDefinitionNode objectTypeDefinitionNode, + DatabaseType databaseType) + { + InputObjectTypeDefinitionNode node; + NameNode inputTypeName = GenerateInputTypeName(typeName); + if (!inputs.ContainsKey(inputTypeName)) + { + node = GenerateCreateInputTypeForNonRelationalDb( + inputs: inputs, + objectTypeDefinitionNode: objectTypeDefinitionNode, + name: field.Type.NamedType().Name, + definitions: definitions, + databaseType: databaseType); } else { node = inputs[inputTypeName]; } - ITypeNode type = new NamedTypeNode(node.Name); + return GetComplexInputType(field, databaseType, node, inputTypeName); + } + /// + /// Creates and returns InputValueDefinitionNode for a field representing a related entity in it's + /// parent's InputObjectTypeDefinitionNode. + /// + /// Related field's definition. + /// Database type. + /// Related field's InputObjectTypeDefinitionNode. + /// Input type name of the parent entity. + /// + private static InputValueDefinitionNode GetComplexInputType( + FieldDefinitionNode relatedFieldDefinition, + DatabaseType databaseType, + InputObjectTypeDefinitionNode relatedFieldInputObjectTypeDefinition, + NameNode parentInputTypeName) + { + ITypeNode type = new NamedTypeNode(relatedFieldInputObjectTypeDefinition.Name); + if (IsRelationalDb(databaseType) && DoesRelationalDBSupportMultipleCreate(databaseType)) + { + if (RelationshipDirectiveType.Cardinality(relatedFieldDefinition) is Cardinality.Many) + { + // For *:N relationships, we need to create a list type. + type = GenerateListType(type, relatedFieldDefinition.Type.InnerType()); + } + + // Since providing input for a relationship field is optional, the type should be nullable. + type = (INullableTypeNode)type; + } // For a type like [Bar!]! we have to first unpack the outer non-null - if (field.Type.IsNonNullType()) + else if (relatedFieldDefinition.Type.IsNonNullType()) { // The innerType is the raw List, scalar or object type without null settings - ITypeNode innerType = field.Type.InnerType(); + ITypeNode innerType = relatedFieldDefinition.Type.InnerType(); if (innerType.IsListType()) { @@ -183,21 +409,34 @@ private static InputValueDefinitionNode GetComplexInputType( // Wrap the input with non-null to match the field definition type = new NonNullTypeNode((INullableTypeNode)type); } - else if (field.Type.IsListType()) + else if (relatedFieldDefinition.Type.IsListType()) { - type = GenerateListType(type, field.Type); + type = GenerateListType(type, relatedFieldDefinition.Type); } return new( location: null, - field.Name, - new StringValueNode($"Input for field {field.Name} on type {inputTypeName}"), - type, + name: relatedFieldDefinition.Name, + description: new StringValueNode($"Input for field {relatedFieldDefinition.Name} on type {parentInputTypeName}"), + type: type, defaultValue: null, - field.Directives + directives: relatedFieldDefinition.Directives ); } + /// + /// Helper method to determine if the relationship defined between the source entity and a particular target entity is an M:N relationship. + /// + /// Source entity. + /// Relationship name. + /// true if the relationship between source and target entities has a cardinality of M:N. + private static bool IsMToNRelationship(Entity sourceEntity, string relationshipName) + { + return sourceEntity.Relationships is not null && + sourceEntity.Relationships.TryGetValue(relationshipName, out EntityRelationship? relationshipInfo) && + !string.IsNullOrWhiteSpace(relationshipInfo.LinkingObject); + } + private static ITypeNode GenerateListType(ITypeNode type, ITypeNode fieldType) { // Look at the inner type of the list type, eg: [Bar]'s inner type is Bar @@ -212,13 +451,13 @@ private static ITypeNode GenerateListType(ITypeNode type, ITypeNode fieldType) /// /// Name of the entity /// InputTypeName - private static NameNode GenerateInputTypeName(string typeName) + public static NameNode GenerateInputTypeName(string typeName) { return new($"{EntityActionOperation.Create}{typeName}Input"); } /// - /// Generate the `create` mutation field for the GraphQL mutations for a given Object Definition + /// Generate the `create` point/multiple mutation fields for the GraphQL mutations for a given Object Definition /// ReturnEntityName can be different from dbEntityName in cases where user wants summary results returned (through the DBOperationResult entity) /// as opposed to full entity. /// @@ -232,7 +471,7 @@ private static NameNode GenerateInputTypeName(string typeName) /// Name of type to be returned by the mutation. /// Collection of role names allowed for action, to be added to authorize directive. /// A GraphQL field definition named create*EntityName* to be attached to the Mutations type in the GraphQL schema. - public static FieldDefinitionNode Build( + public static IEnumerable Build( NameNode name, Dictionary inputs, ObjectTypeDefinitionNode objectTypeDefinitionNode, @@ -243,15 +482,30 @@ public static FieldDefinitionNode Build( string returnEntityName, IEnumerable? rolesAllowedForMutation = null) { + List createMutationNodes = new(); Entity entity = entities[dbEntityName]; - - InputObjectTypeDefinitionNode input = GenerateCreateInputType( - inputs, - objectTypeDefinitionNode, - name, - root.Definitions.Where(d => d is HotChocolate.Language.IHasName).Cast(), - databaseType, - entities); + InputObjectTypeDefinitionNode input; + if (!IsRelationalDb(databaseType)) + { + input = GenerateCreateInputTypeForNonRelationalDb( + inputs, + objectTypeDefinitionNode, + name, + root.Definitions.Where(d => d is HotChocolate.Language.IHasName).Cast(), + databaseType); + } + else + { + input = GenerateCreateInputTypeForRelationalDb( + inputs: inputs, + objectTypeDefinitionNode: objectTypeDefinitionNode, + entityName: dbEntityName, + name: name, + baseEntityName: name, + definitions: root.Definitions.Where(d => d is HotChocolate.Language.IHasName).Cast(), + databaseType: databaseType, + entities: entities); + } List fieldDefinitionNodeDirectives = new() { new(ModelDirectiveType.DirectiveName, new ArgumentNode(ModelDirectiveType.ModelNameArgument, dbEntityName)) }; @@ -264,22 +518,73 @@ public static FieldDefinitionNode Build( } string singularName = GetDefinedSingularName(name.Value, entity); - return new( + + // Create one node. + FieldDefinitionNode createOneNode = new( location: null, - new NameNode($"create{singularName}"), - new StringValueNode($"Creates a new {singularName}"), - new List { - new InputValueDefinitionNode( - location : null, - new NameNode(INPUT_ARGUMENT_NAME), - new StringValueNode($"Input representing all the fields for creating {name}"), - new NonNullTypeNode(new NamedTypeNode(input.Name)), - defaultValue: null, - new List()) + name: new NameNode(GetPointCreateMutationNodeName(name.Value, entity)), + description: new StringValueNode($"Creates a new {singularName}"), + arguments: new List { + new( + location : null, + new NameNode(MutationBuilder.ITEM_INPUT_ARGUMENT_NAME), + new StringValueNode($"Input representing all the fields for creating {name}"), + new NonNullTypeNode(new NamedTypeNode(input.Name)), + defaultValue: null, + new List()) }, - new NamedTypeNode(returnEntityName), - fieldDefinitionNodeDirectives + type: new NamedTypeNode(returnEntityName), + directives: fieldDefinitionNodeDirectives ); + + createMutationNodes.Add(createOneNode); + + if (IsRelationalDb(databaseType) && DoesRelationalDBSupportMultipleCreate(databaseType)) + { + // Create multiple node. + FieldDefinitionNode createMultipleNode = new( + location: null, + name: new NameNode(GetMultipleCreateMutationNodeName(name.Value, entity)), + description: new StringValueNode($"Creates multiple new {GetDefinedPluralName(name.Value, entity)}"), + arguments: new List { + new( + location : null, + new NameNode(MutationBuilder.ARRAY_INPUT_ARGUMENT_NAME), + new StringValueNode($"Input representing all the fields for creating {name}"), + new ListTypeNode(new NonNullTypeNode(new NamedTypeNode(input.Name))), + defaultValue: null, + new List()) + }, + type: new NamedTypeNode(QueryBuilder.GeneratePaginationTypeName(GetDefinedSingularName(dbEntityName, entity))), + directives: fieldDefinitionNodeDirectives + ); + createMutationNodes.Add(createMultipleNode); + } + + return createMutationNodes; + } + + /// + /// Helper method to determine the name of the create one (or point create) mutation. + /// + public static string GetPointCreateMutationNodeName(string entityName, Entity entity) + { + string singularName = GetDefinedSingularName(entityName, entity); + return $"{CREATE_MUTATION_PREFIX}{singularName}"; + } + + /// + /// Helper method to determine the name of the create multiple mutation. + /// If the singular and plural graphql names for the entity match, we suffix the name with 'Multiple' suffix to indicate + /// that the mutation field is created to support insertion of multiple records in the top level entity. + /// However if the plural and singular names are different, we use the plural name to construct the mutation. + /// + public static string GetMultipleCreateMutationNodeName(string entityName, Entity entity) + { + string singularName = GetDefinedSingularName(entityName, entity); + string pluralName = GetDefinedPluralName(entityName, entity); + string mutationName = singularName.Equals(pluralName) ? $"{singularName}{CREATE_MULTIPLE_MUTATION_SUFFIX}" : pluralName; + return $"{CREATE_MUTATION_PREFIX}{mutationName}"; } } } diff --git a/src/Service.GraphQLBuilder/Mutations/MutationBuilder.cs b/src/Service.GraphQLBuilder/Mutations/MutationBuilder.cs index fe87a4e720..95e63210de 100644 --- a/src/Service.GraphQLBuilder/Mutations/MutationBuilder.cs +++ b/src/Service.GraphQLBuilder/Mutations/MutationBuilder.cs @@ -20,7 +20,8 @@ public static class MutationBuilder /// The item field's metadata is of type OperationEntityInput /// i.e. CreateBookInput /// - public const string INPUT_ARGUMENT_NAME = "item"; + public const string ITEM_INPUT_ARGUMENT_NAME = "item"; + public const string ARRAY_INPUT_ARGUMENT_NAME = "items"; /// /// Creates a DocumentNode containing FieldDefinitionNodes representing mutations @@ -47,19 +48,17 @@ public static DocumentNode Build( { string dbEntityName = ObjectTypeToEntityName(objectTypeDefinitionNode); NameNode name = objectTypeDefinitionNode.Name; - + Entity entity = entities[dbEntityName]; // For stored procedures, only one mutation is created in the schema // unlike table/views where we create one for each CUD operation. - if (entities[dbEntityName].Source.Type is EntitySourceType.StoredProcedure) + if (entity.Source.Type is EntitySourceType.StoredProcedure) { // check graphql sp config - string entityName = ObjectTypeToEntityName(objectTypeDefinitionNode); - Entity entity = entities[entityName]; bool isSPDefinedAsMutation = (entity.GraphQL.Operation ?? GraphQLOperation.Mutation) is GraphQLOperation.Mutation; if (isSPDefinedAsMutation) { - if (dbObjects is not null && dbObjects.TryGetValue(entityName, out DatabaseObject? dbObject) && dbObject is not null) + if (dbObjects is not null && dbObjects.TryGetValue(dbEntityName, out DatabaseObject? dbObject) && dbObject is not null) { AddMutationsForStoredProcedure(dbEntityName, entityPermissionsMap, name, entities, mutationFields, dbObject); } @@ -130,7 +129,9 @@ string returnEntityName switch (operation) { case EntityActionOperation.Create: - mutationFields.Add(CreateMutationBuilder.Build(name, inputs, objectTypeDefinitionNode, root, databaseType, entities, dbEntityName, returnEntityName, rolesAllowedForMutation)); + // Get the create one/many fields for the create mutation. + IEnumerable createMutationNodes = CreateMutationBuilder.Build(name, inputs, objectTypeDefinitionNode, root, databaseType, entities, dbEntityName, returnEntityName, rolesAllowedForMutation); + mutationFields.AddRange(createMutationNodes); break; case EntityActionOperation.Update: mutationFields.Add(UpdateMutationBuilder.Build(name, inputs, objectTypeDefinitionNode, root, entities, dbEntityName, databaseType, returnEntityName, rolesAllowedForMutation)); diff --git a/src/Service.GraphQLBuilder/Queries/QueryBuilder.cs b/src/Service.GraphQLBuilder/Queries/QueryBuilder.cs index 0f987b8fe3..b68eb642af 100644 --- a/src/Service.GraphQLBuilder/Queries/QueryBuilder.cs +++ b/src/Service.GraphQLBuilder/Queries/QueryBuilder.cs @@ -248,21 +248,21 @@ public static ObjectTypeDefinitionNode GenerateReturnType(NameNode name) new List(), new List(), new List { - new FieldDefinitionNode( + new( location: null, new NameNode(PAGINATION_FIELD_NAME), new StringValueNode("The list of items that matched the filter"), new List(), new NonNullTypeNode(new ListTypeNode(new NonNullTypeNode(new NamedTypeNode(name)))), new List()), - new FieldDefinitionNode( + new( location : null, new NameNode(PAGINATION_TOKEN_FIELD_NAME), new StringValueNode("A pagination token to provide to subsequent pages of a query"), new List(), new StringType().ToTypeNode(), new List()), - new FieldDefinitionNode( + new( location: null, new NameNode(HAS_NEXT_PAGE_FIELD_NAME), new StringValueNode("Indicates if there are more pages of items to return"), diff --git a/src/Service.GraphQLBuilder/Sql/SchemaConverter.cs b/src/Service.GraphQLBuilder/Sql/SchemaConverter.cs index e2119f04bb..492325b3c5 100644 --- a/src/Service.GraphQLBuilder/Sql/SchemaConverter.cs +++ b/src/Service.GraphQLBuilder/Sql/SchemaConverter.cs @@ -32,146 +32,300 @@ public static class SchemaConverter /// Roles to add to authorize directive at the object level (applies to query/read ops). /// Roles to add to authorize directive at the field level (applies to mutations). /// A GraphQL object type to be provided to a Hot Chocolate GraphQL document. - public static ObjectTypeDefinitionNode FromDatabaseObject( + public static ObjectTypeDefinitionNode GenerateObjectTypeDefinitionForDatabaseObject( string entityName, DatabaseObject databaseObject, [NotNull] Entity configEntity, RuntimeEntities entities, IEnumerable rolesAllowedForEntity, IDictionary> rolesAllowedForFields) + { + ObjectTypeDefinitionNode objectDefinitionNode; + switch (databaseObject.SourceType) + { + case EntitySourceType.StoredProcedure: + objectDefinitionNode = CreateObjectTypeDefinitionForStoredProcedure( + entityName: entityName, + databaseObject: databaseObject, + configEntity: configEntity, + rolesAllowedForEntity: rolesAllowedForEntity, + rolesAllowedForFields: rolesAllowedForFields); + break; + case EntitySourceType.Table: + case EntitySourceType.View: + objectDefinitionNode = CreateObjectTypeDefinitionForTableOrView( + entityName: entityName, + databaseObject: databaseObject, + configEntity: configEntity, + entities: entities, + rolesAllowedForEntity: rolesAllowedForEntity, + rolesAllowedForFields: rolesAllowedForFields); + break; + default: + throw new DataApiBuilderException( + message: $"The source type of entity: {entityName} is not supported", + statusCode: HttpStatusCode.ServiceUnavailable, + subStatusCode: DataApiBuilderException.SubStatusCodes.NotSupported); + } + + return objectDefinitionNode; + } + + /// + /// Helper method to create object type definition for stored procedures. + /// + /// Name of the entity in the runtime config to generate the GraphQL object type for. + /// SQL database object information. + /// Runtime config information for the table. + /// Roles to add to authorize directive at the object level (applies to query/read ops). + /// Roles to add to authorize directive at the field level (applies to mutations). + /// A GraphQL object type for the table/view to be provided to a Hot Chocolate GraphQL document. + private static ObjectTypeDefinitionNode CreateObjectTypeDefinitionForStoredProcedure( + string entityName, + DatabaseObject databaseObject, + Entity configEntity, + IEnumerable rolesAllowedForEntity, + IDictionary> rolesAllowedForFields) { Dictionary fields = new(); - List objectTypeDirectives = new(); - SourceDefinition sourceDefinition = databaseObject.SourceDefinition; - NameNode nameNode = new(value: GetDefinedSingularName(entityName, configEntity)); + SourceDefinition storedProcedureDefinition = databaseObject.SourceDefinition; // When the result set is not defined, it could be a mutation operation with no returning columns // Here we create a field called result which will be an empty array. - if (databaseObject.SourceType is EntitySourceType.StoredProcedure && ((StoredProcedureDefinition)sourceDefinition).Columns.Count == 0) + if (storedProcedureDefinition.Columns.Count == 0) { FieldDefinitionNode field = GetDefaultResultFieldForStoredProcedure(); fields.TryAdd("result", field); } - foreach ((string columnName, ColumnDefinition column) in sourceDefinition.Columns) + foreach ((string columnName, ColumnDefinition column) in storedProcedureDefinition.Columns) { List directives = new(); + // A field is added to the schema when there is atleast one role allowed to access the field. + if (rolesAllowedForFields.TryGetValue(key: columnName, out IEnumerable? roles)) + { + // Even if roles is empty, we create a field for columns returned by a stored-procedures since they only support 1 CRUD action, + // and it's possible that it might return some values during mutation operation (i.e, containing one of create/update/delete permission). + FieldDefinitionNode field = GenerateFieldForColumn(configEntity, columnName, column, directives, roles); + fields.Add(columnName, field); + } + } - if (databaseObject.SourceType is not EntitySourceType.StoredProcedure && sourceDefinition.PrimaryKey.Contains(columnName)) + // Top-level object type definition name should be singular. + // The singularPlural.Singular value is used, and if not configured, + // the top-level entity name value is used. No singularization occurs + // if the top-level entity name is already plural. + return new ObjectTypeDefinitionNode( + location: null, + name: new(value: GetDefinedSingularName(entityName, configEntity)), + description: null, + directives: GenerateObjectTypeDirectivesForEntity(entityName, configEntity, rolesAllowedForEntity), + new List(), + fields.Values.ToImmutableList()); + } + + /// + /// Helper method to create object type definition for database tables or views. + /// + /// Name of the entity in the runtime config to generate the GraphQL object type for. + /// SQL database object information. + /// Runtime config information for the table. + /// Key/Value Collection mapping entity name to the entity object, + /// currently used to lookup relationship metadata. + /// Roles to add to authorize directive at the object level (applies to query/read ops). + /// Roles to add to authorize directive at the field level (applies to mutations). + /// A GraphQL object type for the table/view to be provided to a Hot Chocolate GraphQL document. + private static ObjectTypeDefinitionNode CreateObjectTypeDefinitionForTableOrView( + string entityName, + DatabaseObject databaseObject, + Entity configEntity, + RuntimeEntities entities, + IEnumerable rolesAllowedForEntity, + IDictionary> rolesAllowedForFields) + { + Dictionary fieldDefinitionNodes = new(); + SourceDefinition sourceDefinition = databaseObject.SourceDefinition; + foreach ((string columnName, ColumnDefinition column) in sourceDefinition.Columns) + { + List directives = new(); + if (sourceDefinition.PrimaryKey.Contains(columnName)) { directives.Add(new DirectiveNode(PrimaryKeyDirectiveType.DirectiveName, new ArgumentNode("databaseType", column.SystemType.Name))); } - if (databaseObject.SourceType is not EntitySourceType.StoredProcedure && column.IsReadOnly) + if (column.IsReadOnly) { directives.Add(new DirectiveNode(AutoGeneratedDirectiveType.DirectiveName)); } - if (databaseObject.SourceType is not EntitySourceType.StoredProcedure && column.DefaultValue is not null) + if (column.DefaultValue is not null) { IValueNode arg = CreateValueNodeFromDbObjectMetadata(column.DefaultValue); directives.Add(new DirectiveNode(DefaultValueDirectiveType.DirectiveName, new ArgumentNode("value", arg))); } - // If no roles are allowed for the field, we should not include it in the schema. - // Consequently, the field is only added to schema if this conditional evaluates to TRUE. - if (rolesAllowedForFields.TryGetValue(key: columnName, out IEnumerable? roles)) + // A field is added to the ObjectTypeDefinition when: + // 1. The entity is a linking entity. A linking entity is not exposed by DAB for query/mutation but the fields are required to generate + // object definitions of directional linking entities from source to target. + // 2. The entity is not a linking entity and there is atleast one role allowed to access the field. + if (rolesAllowedForFields.TryGetValue(key: columnName, out IEnumerable? roles) || configEntity.IsLinkingEntity) { // Roles will not be null here if TryGetValue evaluates to true, so here we check if there are any roles to process. - // Since Stored-procedures only support 1 CRUD action, it's possible that stored-procedures might return some values - // during mutation operation (i.e, containing one of create/update/delete permission). - // Hence, this check is bypassed for stored-procedures. - if (roles.Count() > 0 || databaseObject.SourceType is EntitySourceType.StoredProcedure) + // This check is bypassed for linking entities for the same reason explained above. + if (configEntity.IsLinkingEntity || roles is not null && roles.Count() > 0) { - if (GraphQLUtils.CreateAuthorizationDirectiveIfNecessary( - roles, - out DirectiveNode? authZDirective)) - { - directives.Add(authZDirective!); - } - - string exposedColumnName = columnName; - if (configEntity.Mappings is not null && configEntity.Mappings.TryGetValue(key: columnName, out string? columnAlias)) - { - exposedColumnName = columnAlias; - } - - NamedTypeNode fieldType = new(GetGraphQLTypeFromSystemType(column.SystemType)); - FieldDefinitionNode field = new( - location: null, - new(exposedColumnName), - description: null, - new List(), - column.IsNullable ? fieldType : new NonNullTypeNode(fieldType), - directives); - - fields.Add(columnName, field); + FieldDefinitionNode field = GenerateFieldForColumn(configEntity, columnName, column, directives, roles); + fieldDefinitionNodes.Add(columnName, field); } } } - if (configEntity.Relationships is not null) + // A linking entity is not exposed in the runtime config file but is used by DAB to support multiple mutations on entities with M:N relationship. + // Hence we don't need to process relationships for the linking entity itself. + if (!configEntity.IsLinkingEntity) { - foreach ((string relationshipName, EntityRelationship relationship) in configEntity.Relationships) + // For an entity exposed in the config, process the relationships (if there are any) + // sequentially and generate fields for them - to be added to the entity's ObjectTypeDefinition at the end. + if (configEntity.Relationships is not null) { - // Generate the field that represents the relationship to ObjectType, so you can navigate through it - // and walk the graph - string targetEntityName = relationship.TargetEntity.Split('.').Last(); - Entity referencedEntity = entities[targetEntityName]; - - bool isNullableRelationship = FindNullabilityOfRelationship(entityName, databaseObject, targetEntityName); - - INullableTypeNode targetField = relationship.Cardinality switch + foreach ((string relationshipName, EntityRelationship relationship) in configEntity.Relationships) { - Cardinality.One => - new NamedTypeNode(GetDefinedSingularName(targetEntityName, referencedEntity)), - Cardinality.Many => - new NamedTypeNode(QueryBuilder.GeneratePaginationTypeName(GetDefinedSingularName(targetEntityName, referencedEntity))), - _ => - throw new DataApiBuilderException( - message: "Specified cardinality isn't supported", - statusCode: HttpStatusCode.InternalServerError, - subStatusCode: DataApiBuilderException.SubStatusCodes.GraphQLMapping), - }; - - FieldDefinitionNode relationshipField = new( - location: null, - new NameNode(relationshipName), - description: null, - new List(), - isNullableRelationship ? targetField : new NonNullTypeNode(targetField), - new List { - new(RelationshipDirectiveType.DirectiveName, - new ArgumentNode("target", GetDefinedSingularName(targetEntityName, referencedEntity)), - new ArgumentNode("cardinality", relationship.Cardinality.ToString())) - }); - - fields.Add(relationshipField.Name.Value, relationshipField); + FieldDefinitionNode relationshipField = GenerateFieldForRelationship( + entityName, + databaseObject, + entities, + relationshipName, + relationship); + fieldDefinitionNodes.Add(relationshipField.Name.Value, relationshipField); + } } } - objectTypeDirectives.Add(new(ModelDirectiveType.DirectiveName, new ArgumentNode("name", entityName))); - - if (GraphQLUtils.CreateAuthorizationDirectiveIfNecessary( - rolesAllowedForEntity, - out DirectiveNode? authorizeDirective)) - { - objectTypeDirectives.Add(authorizeDirective!); - } - // Top-level object type definition name should be singular. // The singularPlural.Singular value is used, and if not configured, // the top-level entity name value is used. No singularization occurs // if the top-level entity name is already plural. return new ObjectTypeDefinitionNode( location: null, - name: nameNode, + name: new(value: GetDefinedSingularName(entityName, configEntity)), description: null, - objectTypeDirectives, + directives: GenerateObjectTypeDirectivesForEntity(entityName, configEntity, rolesAllowedForEntity), new List(), - fields.Values.ToImmutableList()); + fieldDefinitionNodes.Values.ToImmutableList()); + } + + /// + /// Helper method to generate the FieldDefinitionNode for a column in a table/view or a result set field in a stored-procedure. + /// + /// Entity's definition (to which the column belongs). + /// Backing column name. + /// Column definition. + /// List of directives to be added to the column's field definition. + /// List of roles having read permission on the column (for tables/views) or execute permission for stored-procedure. + /// Generated field definition node for the column to be used in the entity's object type definition. + private static FieldDefinitionNode GenerateFieldForColumn(Entity configEntity, string columnName, ColumnDefinition column, List directives, IEnumerable? roles) + { + if (GraphQLUtils.CreateAuthorizationDirectiveIfNecessary( + roles, + out DirectiveNode? authZDirective)) + { + directives.Add(authZDirective!); + } + + string exposedColumnName = columnName; + if (configEntity.Mappings is not null && configEntity.Mappings.TryGetValue(key: columnName, out string? columnAlias)) + { + exposedColumnName = columnAlias; + } + + NamedTypeNode fieldType = new(GetGraphQLTypeFromSystemType(column.SystemType)); + FieldDefinitionNode field = new( + location: null, + new(exposedColumnName), + description: null, + new List(), + column.IsNullable ? fieldType : new NonNullTypeNode(fieldType), + directives); + return field; + } + + /// + /// Helper method to generate field for a relationship for an entity. These relationship fields are populated with relationship directive + /// which stores the (cardinality, target entity) for the relationship. This enables nested queries/multiple mutations on the relationship fields. + /// + /// While processing the relationship, it helps in keeping track of fields from the source entity which hold foreign key references to the target entity. + /// + /// Name of the entity in the runtime config to generate the GraphQL object type for. + /// SQL database object information. + /// Key/Value Collection mapping entity name to the entity object, currently used to lookup relationship metadata. + /// Name of the relationship. + /// Relationship data. + private static FieldDefinitionNode GenerateFieldForRelationship( + string entityName, + DatabaseObject databaseObject, + RuntimeEntities entities, + string relationshipName, + EntityRelationship relationship) + { + // Generate the field that represents the relationship to ObjectType, so you can navigate through it + // and walk the graph. + string targetEntityName = relationship.TargetEntity.Split('.').Last(); + Entity referencedEntity = entities[targetEntityName]; + bool isNullableRelationship = FindNullabilityOfRelationship(entityName, databaseObject, targetEntityName); + + INullableTypeNode targetField = relationship.Cardinality switch + { + Cardinality.One => + new NamedTypeNode(GetDefinedSingularName(targetEntityName, referencedEntity)), + Cardinality.Many => + new NamedTypeNode(QueryBuilder.GeneratePaginationTypeName(GetDefinedSingularName(targetEntityName, referencedEntity))), + _ => + throw new DataApiBuilderException( + message: "Specified cardinality isn't supported", + statusCode: HttpStatusCode.InternalServerError, + subStatusCode: DataApiBuilderException.SubStatusCodes.GraphQLMapping), + }; + + FieldDefinitionNode relationshipField = new( + location: null, + new NameNode(relationshipName), + description: null, + new List(), + isNullableRelationship ? targetField : new NonNullTypeNode(targetField), + new List { + new(RelationshipDirectiveType.DirectiveName, + new ArgumentNode("target", GetDefinedSingularName(targetEntityName, referencedEntity)), + new ArgumentNode("cardinality", relationship.Cardinality.ToString())) + }); + + return relationshipField; + } + + /// + /// Helper method to generate the list of directives for an entity's object type definition. + /// Generates and returns the authorize and model directives to be later added to the object's definition. + /// + /// Name of the entity for whose object type definition, the list of directives are to be created. + /// Entity definition. + /// Roles to add to authorize directive at the object level (applies to query/read ops). + /// List of directives for the object definition of the entity. + private static List GenerateObjectTypeDirectivesForEntity(string entityName, Entity configEntity, IEnumerable rolesAllowedForEntity) + { + List objectTypeDirectives = new(); + if (!configEntity.IsLinkingEntity) + { + objectTypeDirectives.Add(new(ModelDirectiveType.DirectiveName, new ArgumentNode("name", entityName))); + if (GraphQLUtils.CreateAuthorizationDirectiveIfNecessary( + rolesAllowedForEntity, + out DirectiveNode? authorizeDirective)) + { + objectTypeDirectives.Add(authorizeDirective!); + } + } + + return objectTypeDirectives; } /// diff --git a/src/Service.Tests/Authorization/GraphQL/GraphQLMutationAuthorizationTests.cs b/src/Service.Tests/Authorization/GraphQL/GraphQLMutationAuthorizationTests.cs index c2ffdcc90e..a9355796ac 100644 --- a/src/Service.Tests/Authorization/GraphQL/GraphQLMutationAuthorizationTests.cs +++ b/src/Service.Tests/Authorization/GraphQL/GraphQLMutationAuthorizationTests.cs @@ -69,7 +69,7 @@ public void MutationFields_AuthorizationEvaluation(bool isAuthorized, string[] c Dictionary parameters = new() { - { MutationBuilder.INPUT_ARGUMENT_NAME, mutationInputRaw } + { MutationBuilder.ITEM_INPUT_ARGUMENT_NAME, mutationInputRaw } }; Dictionary middlewareContextData = new() diff --git a/src/Service.Tests/DatabaseSchema-MsSql.sql b/src/Service.Tests/DatabaseSchema-MsSql.sql index 0c8d9c4a5c..4c44c27459 100644 --- a/src/Service.Tests/DatabaseSchema-MsSql.sql +++ b/src/Service.Tests/DatabaseSchema-MsSql.sql @@ -109,6 +109,7 @@ CREATE TABLE reviews( CREATE TABLE book_author_link( book_id int NOT NULL, author_id int NOT NULL, + royalty_percentage float DEFAULT 0 NULL, PRIMARY KEY(book_id, author_id) ); diff --git a/src/Service.Tests/GraphQLBuilder/MsSqlMultipleMutationBuilderTests.cs b/src/Service.Tests/GraphQLBuilder/MsSqlMultipleMutationBuilderTests.cs new file mode 100644 index 0000000000..942c8b4ade --- /dev/null +++ b/src/Service.Tests/GraphQLBuilder/MsSqlMultipleMutationBuilderTests.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Azure.DataApiBuilder.Service.Tests.GraphQLBuilder +{ + [TestClass, TestCategory(TestCategory.MSSQL)] + public class MsSqlMultipleMutationBuilderTests : MultipleMutationBuilderTests + { + [ClassInitialize] + public static async Task SetupAsync(TestContext context) + { + databaseEngine = TestCategory.MSSQL; + await InitializeAsync(); + } + } +} diff --git a/src/Service.Tests/GraphQLBuilder/MultipleMutationBuilderTests.cs b/src/Service.Tests/GraphQLBuilder/MultipleMutationBuilderTests.cs new file mode 100644 index 0000000000..714a80c4d2 --- /dev/null +++ b/src/Service.Tests/GraphQLBuilder/MultipleMutationBuilderTests.cs @@ -0,0 +1,429 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Azure.DataApiBuilder.Auth; +using Azure.DataApiBuilder.Config; +using Azure.DataApiBuilder.Config.ObjectModel; +using Azure.DataApiBuilder.Core.Authorization; +using Azure.DataApiBuilder.Core.Configurations; +using Azure.DataApiBuilder.Core.Models; +using Azure.DataApiBuilder.Core.Resolvers; +using Azure.DataApiBuilder.Core.Resolvers.Factories; +using Azure.DataApiBuilder.Core.Services; +using Azure.DataApiBuilder.Core.Services.Cache; +using Azure.DataApiBuilder.Core.Services.MetadataProviders; +using Azure.DataApiBuilder.Service.GraphQLBuilder; +using Azure.DataApiBuilder.Service.GraphQLBuilder.Directives; +using Azure.DataApiBuilder.Service.GraphQLBuilder.Mutations; +using HotChocolate.Language; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using ZiggyCreatures.Caching.Fusion; +using static Azure.DataApiBuilder.Service.GraphQLBuilder.GraphQLNaming; + +namespace Azure.DataApiBuilder.Service.Tests.GraphQLBuilder +{ + /// + /// Parent class containing tests to validate different aspects of schema generation for multiple mutations for different types of + /// relational database flavours supported by DAB. All the tests in the class validate the side effect of the GraphQL schema created + /// as a result of the execution of the InitializeAsync method. + /// + [TestClass] + public abstract class MultipleMutationBuilderTests + { + // Stores the type of database - MsSql, MySql, PgSql, DwSql. Currently multiple mutations are only supported for MsSql. + protected static string databaseEngine; + + // Stores mutation definitions for entities. + private static IEnumerable _mutationDefinitions; + + // Stores object definitions for entities. + private static IEnumerable _objectDefinitions; + + // Runtime config instance. + private static RuntimeConfig _runtimeConfig; + + #region Multiple Create tests + + /// + /// Test to validate that we don't expose the object definitions inferred for linking entity/table to the end user as that is an information + /// leak. These linking object definitions are only used to generate the final source->target linking object definitions for entities + /// having an M:N relationship between them. + /// + [TestMethod] + public void ValidateAbsenceOfLinkingObjectDefinitionsInObjectsNodeForMNRelationships() + { + // Name of the source entity for which the configuration is provided in the config. + string sourceEntityName = "Book"; + + // Name of the target entity which is related to the source entity via a relationship defined in the 'relationships' + // section in the configuration of the source entity. + string targetEntityName = "Author"; + string linkingEntityName = GraphQLUtils.GenerateLinkingEntityName(sourceEntityName, targetEntityName); + ObjectTypeDefinitionNode linkingObjectTypeDefinitionNode = GetObjectTypeDefinitionNode(linkingEntityName); + + // Validate absence of linking object for Book->Author M:N relationship. + // The object definition being null here implies that the object definition is not exposed in the objects node. + Assert.IsNull(linkingObjectTypeDefinitionNode); + } + + /// + /// Test to validate the functionality of GraphQLSchemaCreator.GenerateSourceTargetLinkingObjectDefinitions() and to ensure that + /// we create a source -> target linking object definition for every pair of (source, target) entities which + /// are related via an M:N relationship. + /// + [TestMethod] + public void ValidatePresenceOfSourceTargetLinkingObjectDefinitionsInObjectsNodeForMNRelationships() + { + // Name of the source entity for which the configuration is provided in the config. + string sourceEntityName = "Book"; + + // Name of the target entity which is related to the source entity via a relationship defined in the 'relationships' + // section in the configuration of the source entity. + string targetEntityName = "Author"; + string sourceTargetLinkingNodeName = GenerateLinkingNodeName( + GetDefinedSingularName(sourceEntityName, _runtimeConfig.Entities[sourceEntityName]), + GetDefinedSingularName(targetEntityName, _runtimeConfig.Entities[targetEntityName])); + ObjectTypeDefinitionNode sourceTargetLinkingObjectTypeDefinitionNode = GetObjectTypeDefinitionNode(sourceTargetLinkingNodeName); + + // Validate presence of source->target linking object for Book->Author M:N relationship. + Assert.IsNotNull(sourceTargetLinkingObjectTypeDefinitionNode); + } + + /// + /// Test to validate that we add a referencing field directive to the list of directives for every column in an entity/table, + /// which is a referencing field to another field in any entity in the config. + /// + [TestMethod] + public void ValidatePresenceOfOneReferencingFieldDirectiveOnReferencingColumns() + { + // Name of the referencing entity. + string referencingEntityName = "Book"; + + // List of referencing columns. + string[] referencingColumns = new string[] { "publisher_id" }; + ObjectTypeDefinitionNode objectTypeDefinitionNode = GetObjectTypeDefinitionNode( + GetDefinedSingularName( + entityName: referencingEntityName, + configEntity: _runtimeConfig.Entities[referencingEntityName])); + List fieldsInObjectDefinitionNode = objectTypeDefinitionNode.Fields.ToList(); + foreach (string referencingColumn in referencingColumns) + { + int indexOfReferencingField = fieldsInObjectDefinitionNode.FindIndex((field => field.Name.Value.Equals(referencingColumn))); + FieldDefinitionNode referencingFieldDefinition = fieldsInObjectDefinitionNode[indexOfReferencingField]; + int countOfReferencingFieldDirectives = referencingFieldDefinition.Directives.Where(directive => directive.Name.Value == ReferencingFieldDirectiveType.DirectiveName).Count(); + // The presence of 1 referencing field directive indicates: + // 1. The foreign key dependency was successfully inferred from the metadata. + // 2. The referencing field directive was added only once. When a relationship between two entities is defined in the configuration of both the entities, + // we want to ensure that we don't unnecessarily add the referencing field directive twice for the referencing fields. + Assert.AreEqual(1, countOfReferencingFieldDirectives); + } + } + + /// + /// Test to validate that we don't erroneously add a referencing field directive to the list of directives for every column in an entity/table, + /// which is not a referencing field to another field in any entity in the config. + /// + [TestMethod] + public void ValidateAbsenceOfReferencingFieldDirectiveOnNonReferencingColumns() + { + // Name of the referencing entity. + string referencingEntityName = "stocks_price"; + + // List of expected referencing columns. + HashSet expectedReferencingColumns = new() { "categoryid", "pieceid" }; + ObjectTypeDefinitionNode actualObjectTypeDefinitionNode = GetObjectTypeDefinitionNode( + GetDefinedSingularName( + entityName: referencingEntityName, + configEntity: _runtimeConfig.Entities[referencingEntityName])); + List actualFieldsInObjectDefinitionNode = actualObjectTypeDefinitionNode.Fields.ToList(); + foreach (FieldDefinitionNode fieldInObjectDefinitionNode in actualFieldsInObjectDefinitionNode) + { + if (!expectedReferencingColumns.Contains(fieldInObjectDefinitionNode.Name.Value)) + { + int countOfReferencingFieldDirectives = fieldInObjectDefinitionNode.Directives.Where(directive => directive.Name.Value == ReferencingFieldDirectiveType.DirectiveName).Count(); + Assert.AreEqual(0, countOfReferencingFieldDirectives, message: "Scalar fields should not have referencing field directives."); + } + } + } + + /// + /// Test to validate that both create one, and create multiple mutations are created for entities. + /// + [TestMethod] + public void ValidateCreationOfPointAndMultipleCreateMutations() + { + string entityName = "Publisher"; + string createOneMutationName = CreateMutationBuilder.GetPointCreateMutationNodeName(entityName, _runtimeConfig.Entities[entityName]); + string createMultipleMutationName = CreateMutationBuilder.GetMultipleCreateMutationNodeName(entityName, _runtimeConfig.Entities[entityName]); + + ObjectTypeDefinitionNode mutationObjectDefinition = (ObjectTypeDefinitionNode)_mutationDefinitions.FirstOrDefault(d => d.Name.Value == "Mutation"); + + // The index of create one mutation not being equal to -1 indicates that we successfully created the mutation. + int indexOfCreateOneMutationField = mutationObjectDefinition.Fields.ToList().FindIndex(f => f.Name.Value.Equals(createOneMutationName)); + Assert.AreNotEqual(-1, indexOfCreateOneMutationField); + + // The index of create multiple mutation not being equal to -1 indicates that we successfully created the mutation. + int indexOfCreateMultipleMutationField = mutationObjectDefinition.Fields.ToList().FindIndex(f => f.Name.Value.Equals(createMultipleMutationName)); + Assert.AreNotEqual(-1, indexOfCreateMultipleMutationField); + } + + /// + /// Test to validate that in addition to column fields, relationship fields are also processed for creating the 'create' input object types. + /// This test validates that in the create'' input object type for the entity: + /// 1. A relationship field is created for every relationship defined in the 'relationships' section of the entity. + /// 2. The type of the relationship field (which represents input for the target entity) is nullable. + /// This ensures that providing input for relationship fields is optional. + /// 3. For relationships with cardinality (for target entity) as 'Many', the relationship field type is a list type - + /// to allow creating multiple records in the target entity. For relationships with cardinality 'One', + /// the relationship field type should not be a list type (and hence should be an object type). + /// + [TestMethod] + public void ValidateRelationshipFieldsInInputType() + { + string entityName = "Book"; + Entity entity = _runtimeConfig.Entities[entityName]; + NameNode inputTypeName = CreateMutationBuilder.GenerateInputTypeName(GetDefinedSingularName(entityName, entity)); + InputObjectTypeDefinitionNode inputObjectTypeDefinition = (InputObjectTypeDefinitionNode)_mutationDefinitions.FirstOrDefault(d => d.Name.Value.Equals(inputTypeName.Value)); + List inputFields = inputObjectTypeDefinition.Fields.ToList(); + HashSet inputFieldNames = new(inputObjectTypeDefinition.Fields.Select(field => field.Name.Value)); + foreach ((string relationshipName, EntityRelationship relationship) in entity.Relationships) + { + // Assert that the input type for the entity contains a field for the relationship. + Assert.AreEqual(true, inputFieldNames.Contains(relationshipName)); + + int indexOfRelationshipField = inputFields.FindIndex(field => field.Name.Value.Equals(relationshipName)); + InputValueDefinitionNode inputValueDefinitionNode = inputFields[indexOfRelationshipField]; + + // Assert that the field should be of nullable type as providing input for relationship fields is optional. + Assert.AreEqual(true, !inputValueDefinitionNode.Type.IsNonNullType()); + if (relationship.Cardinality is Cardinality.Many) + { + // For relationship with cardinality as 'Many', assert that we create a list input type. + Assert.AreEqual(true, inputValueDefinitionNode.Type.IsListType()); + } + else + { + // For relationship with cardinality as 'One', assert that we don't create a list type, + // but an object type. + Assert.AreEqual(false, inputValueDefinitionNode.Type.IsListType()); + } + } + } + + /// + /// Test to validate that for entities having an M:N relationship between them, we create a source->target linking input type. + /// + [TestMethod] + public void ValidateCreationOfSourceTargetLinkingInputForMNRelationship() + { + // Name of the source entity for which the configuration is provided in the config. + string sourceEntityName = "Book"; + + // Name of the target entity which is related to the source entity via a relationship defined in the 'relationships' + // section in the configuration of the source entity. + string targetEntityName = "Author"; + + NameNode inputTypeNameForBook = CreateMutationBuilder.GenerateInputTypeName(GetDefinedSingularName( + sourceEntityName, + _runtimeConfig.Entities[sourceEntityName])); + InputObjectTypeDefinitionNode inputObjectTypeDefinition = (InputObjectTypeDefinitionNode)_mutationDefinitions.FirstOrDefault(d => d.Name.Value.Equals(inputTypeNameForBook.Value)); + NameNode inputTypeName = CreateMutationBuilder.GenerateInputTypeName(GenerateLinkingNodeName( + GetDefinedSingularName(sourceEntityName, _runtimeConfig.Entities[sourceEntityName]), + GetDefinedSingularName(targetEntityName, _runtimeConfig.Entities[targetEntityName]))); + List inputFields = inputObjectTypeDefinition.Fields.ToList(); + int indexOfRelationshipField = inputFields.FindIndex(field => field.Type.InnerType().NamedType().Name.Value.Equals(inputTypeName.Value)); + + // Validate creation of source->target linking input object for Book->Author M:N relationship + Assert.AreNotEqual(-1, indexOfRelationshipField); + } + + /// + /// Test to validate that the linking input types generated for a source->target relationship contains input fields for: + /// 1. All the fields belonging to the target entity, and + /// 2. All the non-relationship fields in the linking entity. + /// + [TestMethod] + public void ValidateInputForMNRelationship() + { + // Name of the source entity for which the configuration is provided in the config. + string sourceEntityName = "Book"; + + // Name of the target entity which is related to the source entity via a relationship defined in the 'relationships' + // section in the configuration of the source entity. + string targetEntityName = "Author"; + string linkingObjectFieldName = "royalty_percentage"; + string sourceNodeName = GetDefinedSingularName(sourceEntityName, _runtimeConfig.Entities[sourceEntityName]); + string targetNodeName = GetDefinedSingularName(targetEntityName, _runtimeConfig.Entities[targetEntityName]); + + // Get input object definition for target entity. + NameNode targetInputTypeName = CreateMutationBuilder.GenerateInputTypeName(targetNodeName); + InputObjectTypeDefinitionNode targetInputObjectTypeDefinitionNode = (InputObjectTypeDefinitionNode)_mutationDefinitions.FirstOrDefault(d => d.Name.Value.Equals(targetInputTypeName.Value)); + + // Get input object definition for source->target linking node. + NameNode sourceTargetLinkingInputTypeName = CreateMutationBuilder.GenerateInputTypeName(GenerateLinkingNodeName(sourceNodeName, targetNodeName)); + InputObjectTypeDefinitionNode sourceTargetLinkingInputObjectTypeDefinition = (InputObjectTypeDefinitionNode)_mutationDefinitions.FirstOrDefault(d => d.Name.Value.Equals(sourceTargetLinkingInputTypeName.Value)); + + // Collect all input field names in the source->target linking node input object definition. + HashSet inputFieldNamesInSourceTargetLinkingInput = new(sourceTargetLinkingInputObjectTypeDefinition.Fields.Select(field => field.Name.Value)); + + // Assert that all the fields from the target input definition are present in the source->target linking input definition. + foreach (InputValueDefinitionNode targetInputValueField in targetInputObjectTypeDefinitionNode.Fields) + { + Assert.AreEqual(true, inputFieldNamesInSourceTargetLinkingInput.Contains(targetInputValueField.Name.Value)); + } + + // Assert that the fields ('royalty_percentage') from linking object (i.e. book_author_link) is also + // present in the input fields for the source>target linking input definition. + Assert.AreEqual(true, inputFieldNamesInSourceTargetLinkingInput.Contains(linkingObjectFieldName)); + } + + /// + /// Test to validate that in the 'create' input type for an entity, all the columns from the entity which hold a foreign key reference to + /// some other entity in the config are of nullable type. Making the FK referencing columns nullable allows the user to not specify them. + /// In such a case, for a valid mutation request, the value for these referencing columns is derived from the insertion in the referenced entity. + /// + [TestMethod] + public void ValidateNullabilityOfReferencingColumnsInInputType() + { + string referencingEntityName = "Book"; + + // Relationship: books.publisher_id -> publishers.id + string[] referencingColumns = new string[] { "publisher_id" }; + Entity entity = _runtimeConfig.Entities[referencingEntityName]; + NameNode inputTypeName = CreateMutationBuilder.GenerateInputTypeName(GetDefinedSingularName(referencingEntityName, entity)); + InputObjectTypeDefinitionNode inputObjectTypeDefinition = (InputObjectTypeDefinitionNode)_mutationDefinitions.FirstOrDefault(d => d.Name.Value.Equals(inputTypeName.Value)); + List inputFields = inputObjectTypeDefinition.Fields.ToList(); + foreach (string referencingColumn in referencingColumns) + { + int indexOfReferencingColumn = inputFields.FindIndex(field => field.Name.Value.Equals(referencingColumn)); + InputValueDefinitionNode inputValueDefinitionNode = inputFields[indexOfReferencingColumn]; + + // The field should be of nullable type as providing input for referencing fields is optional. + Assert.AreEqual(true, !inputValueDefinitionNode.Type.IsNonNullType()); + } + } + #endregion + + #region Helpers + + /// + /// Given a node name (singular name for an entity), returns the object definition created for the node. + /// + private static ObjectTypeDefinitionNode GetObjectTypeDefinitionNode(string nodeName) + { + IHasName definition = _objectDefinitions.FirstOrDefault(d => d.Name.Value == nodeName); + return definition is ObjectTypeDefinitionNode objectTypeDefinitionNode ? objectTypeDefinitionNode : null; + } + #endregion + + #region Test setup + + /// + /// Initializes the class variables to be used throughout the tests. + /// + public static async Task InitializeAsync() + { + // Setup runtime config. + RuntimeConfigProvider runtimeConfigProvider = GetRuntimeConfigProvider(); + _runtimeConfig = runtimeConfigProvider.GetConfig(); + + // Collect object definitions for entities. + GraphQLSchemaCreator schemaCreator = await GetGQLSchemaCreator(runtimeConfigProvider); + (DocumentNode objectsNode, Dictionary inputTypes) = schemaCreator.GenerateGraphQLObjects(); + _objectDefinitions = objectsNode.Definitions.Where(d => d is IHasName).Cast(); + + // Collect mutation definitions for entities. + (_, DocumentNode mutationsNode) = schemaCreator.GenerateQueryAndMutationNodes(objectsNode, inputTypes); + _mutationDefinitions = mutationsNode.Definitions.Where(d => d is IHasName).Cast(); + } + + /// + /// Sets up and returns a runtime config provider instance. + /// + private static RuntimeConfigProvider GetRuntimeConfigProvider() + { + TestHelper.SetupDatabaseEnvironment(databaseEngine); + // Get the base config file from disk + FileSystemRuntimeConfigLoader configPath = TestHelper.GetRuntimeConfigLoader(); + return new(configPath); + } + + /// + /// Sets up and returns a GraphQL schema creator instance. + /// + private static async Task GetGQLSchemaCreator(RuntimeConfigProvider runtimeConfigProvider) + { + // Setup mock loggers. + Mock httpContextAccessor = new(); + Mock> executorLogger = new(); + Mock> metadatProviderLogger = new(); + Mock> queryEngineLogger = new(); + + // Setup mock cache and cache service. + Mock cache = new(); + DabCacheService cacheService = new(cache: cache.Object, logger: null, httpContextAccessor: httpContextAccessor.Object); + + // Setup query manager factory. + IAbstractQueryManagerFactory queryManagerfactory = new QueryManagerFactory( + runtimeConfigProvider: runtimeConfigProvider, + logger: executorLogger.Object, + contextAccessor: httpContextAccessor.Object); + + // Setup metadata provider factory. + IMetadataProviderFactory metadataProviderFactory = new MetadataProviderFactory( + runtimeConfigProvider: runtimeConfigProvider, + queryManagerFactory: queryManagerfactory, + logger: metadatProviderLogger.Object, + fileSystem: null); + + // Collecte all the metadata from the database. + await metadataProviderFactory.InitializeAsync(); + + // Setup GQL filter parser. + GQLFilterParser graphQLFilterParser = new(runtimeConfigProvider: runtimeConfigProvider, metadataProviderFactory: metadataProviderFactory); + + // Setup Authorization resolver. + IAuthorizationResolver authorizationResolver = new AuthorizationResolver( + runtimeConfigProvider: runtimeConfigProvider, + metadataProviderFactory: metadataProviderFactory); + + // Setup query engine factory. + IQueryEngineFactory queryEngineFactory = new QueryEngineFactory( + runtimeConfigProvider: runtimeConfigProvider, + queryManagerFactory: queryManagerfactory, + metadataProviderFactory: metadataProviderFactory, + cosmosClientProvider: null, + contextAccessor: httpContextAccessor.Object, + authorizationResolver: authorizationResolver, + gQLFilterParser: graphQLFilterParser, + logger: queryEngineLogger.Object, + cache: cacheService); + + // Setup mock mutation engine factory. + Mock mutationEngineFactory = new(); + + // Return the setup GraphQL schema creator instance. + return new GraphQLSchemaCreator( + runtimeConfigProvider: runtimeConfigProvider, + queryEngineFactory: queryEngineFactory, + mutationEngineFactory: mutationEngineFactory.Object, + metadataProviderFactory: metadataProviderFactory, + authorizationResolver: authorizationResolver); + } + #endregion + + #region Clean up + [TestCleanup] + public void CleanupAfterEachTest() + { + TestHelper.UnsetAllDABEnvironmentVariables(); + } + #endregion + } +} diff --git a/src/Service.Tests/GraphQLBuilder/MutationBuilderTests.cs b/src/Service.Tests/GraphQLBuilder/MutationBuilderTests.cs index b8778b30c0..2c7e3ae22c 100644 --- a/src/Service.Tests/GraphQLBuilder/MutationBuilderTests.cs +++ b/src/Service.Tests/GraphQLBuilder/MutationBuilderTests.cs @@ -124,8 +124,8 @@ type Foo @model(name:""Foo"") { ), "The type Date is not a known GraphQL type, and cannot be used in this schema." ); - Assert.AreEqual(HttpStatusCode.InternalServerError, ex.StatusCode); - Assert.AreEqual(DataApiBuilderException.SubStatusCodes.GraphQLMapping, ex.SubStatusCode); + Assert.AreEqual(HttpStatusCode.ServiceUnavailable, ex.StatusCode); + Assert.AreEqual(DataApiBuilderException.SubStatusCodes.ErrorInInitialization, ex.SubStatusCode); } [TestMethod] @@ -908,46 +908,6 @@ type Bar @model(name:""Bar""){ Assert.IsFalse(inputObj.Fields[1].Type.InnerType().IsNonNullType(), "list fields should be nullable"); } - [TestMethod] - [TestCategory("Mutation Builder - Create")] - public void CreateMutationWontCreateNestedModelsOnInput() - { - string gql = - @" -type Foo @model(name:""Foo"") { - id: ID! - baz: Baz! -} - -type Baz @model(name:""Baz"") { - id: ID! - x: String! -} - "; - - DocumentNode root = Utf8GraphQLParser.Parse(gql); - - Dictionary entityNameToDatabaseType = new() - { - { "Foo", DatabaseType.MSSQL }, - { "Baz", DatabaseType.MSSQL } - }; - DocumentNode mutationRoot = MutationBuilder.Build( - root, - entityNameToDatabaseType, - new(new Dictionary { { "Foo", GenerateEmptyEntity() }, { "Baz", GenerateEmptyEntity() } }), - entityPermissionsMap: _entityPermissions - ); - - ObjectTypeDefinitionNode query = GetMutationNode(mutationRoot); - FieldDefinitionNode field = query.Fields.First(f => f.Name.Value == $"createFoo"); - Assert.AreEqual(1, field.Arguments.Count); - - InputObjectTypeDefinitionNode argType = (InputObjectTypeDefinitionNode)mutationRoot.Definitions.First(d => d is INamedSyntaxNode node && node.Name == field.Arguments[0].Type.NamedType().Name); - Assert.AreEqual(1, argType.Fields.Count); - Assert.AreEqual("id", argType.Fields[0].Name.Value); - } - [TestMethod] [TestCategory("Mutation Builder - Create")] public void CreateMutationWillCreateNestedModelsOnInputForCosmos() @@ -1112,8 +1072,24 @@ string[] expectedNames // The permissions are setup for create, update and delete operations. // So create, update and delete mutations should get generated. - // A Check to validate that the count of mutations generated is 3. - Assert.AreEqual(3 * entityNames.Length, mutation.Fields.Count); + // A Check to validate that the count of mutations generated is 4 - + // 1. 2 Create mutations (point/many) when db supports nested created, else 1. + // 2. 1 Update mutation + // 3. 1 Delete mutation + int totalExpectedMutations = 0; + foreach ((_, DatabaseType dbType) in entityNameToDatabaseType) + { + if (GraphQLUtils.DoesRelationalDBSupportMultipleCreate(dbType)) + { + totalExpectedMutations += 4; + } + else + { + totalExpectedMutations += 3; + } + } + + Assert.AreEqual(totalExpectedMutations, mutation.Fields.Count); for (int i = 0; i < entityNames.Length; i++) { diff --git a/src/Service.Tests/GraphQLBuilder/Sql/SchemaConverterTests.cs b/src/Service.Tests/GraphQLBuilder/Sql/SchemaConverterTests.cs index 23687524e1..8d3f9851d6 100644 --- a/src/Service.Tests/GraphQLBuilder/Sql/SchemaConverterTests.cs +++ b/src/Service.Tests/GraphQLBuilder/Sql/SchemaConverterTests.cs @@ -40,7 +40,7 @@ public void EntityNameBecomesObjectName(string entityName, string expected) { DatabaseObject dbObject = new DatabaseTable() { TableDefinition = new() }; - ObjectTypeDefinitionNode od = SchemaConverter.FromDatabaseObject( + ObjectTypeDefinitionNode od = SchemaConverter.GenerateObjectTypeDefinitionForDatabaseObject( entityName, dbObject, GenerateEmptyEntity(entityName), @@ -70,7 +70,7 @@ public void ColumnNameBecomesFieldName(string columnName, string expected) DatabaseObject dbObject = new DatabaseTable() { TableDefinition = table }; - ObjectTypeDefinitionNode od = SchemaConverter.FromDatabaseObject( + ObjectTypeDefinitionNode od = SchemaConverter.GenerateObjectTypeDefinitionForDatabaseObject( "table", dbObject, GenerateEmptyEntity("table"), @@ -112,7 +112,7 @@ public void FieldNameMatchesMappedValue(bool setMappings, string backingColumnNa Entity configEntity = GenerateEmptyEntity("table") with { Mappings = mappings }; - ObjectTypeDefinitionNode od = SchemaConverter.FromDatabaseObject( + ObjectTypeDefinitionNode od = SchemaConverter.GenerateObjectTypeDefinitionForDatabaseObject( "table", dbObject, configEntity, @@ -145,7 +145,7 @@ public void PrimaryKeyColumnHasAppropriateDirective() DatabaseObject dbObject = new DatabaseTable() { TableDefinition = table }; - ObjectTypeDefinitionNode od = SchemaConverter.FromDatabaseObject( + ObjectTypeDefinitionNode od = SchemaConverter.GenerateObjectTypeDefinitionForDatabaseObject( "table", dbObject, GenerateEmptyEntity("table"), @@ -174,7 +174,7 @@ public void MultiplePrimaryKeysAllMappedWithDirectives() DatabaseObject dbObject = new DatabaseTable() { TableDefinition = table }; - ObjectTypeDefinitionNode od = SchemaConverter.FromDatabaseObject( + ObjectTypeDefinitionNode od = SchemaConverter.GenerateObjectTypeDefinitionForDatabaseObject( "table", dbObject, GenerateEmptyEntity("table"), @@ -204,7 +204,7 @@ public void MultipleColumnsAllMapped() DatabaseObject dbObject = new DatabaseTable() { TableDefinition = table }; - ObjectTypeDefinitionNode od = SchemaConverter.FromDatabaseObject( + ObjectTypeDefinitionNode od = SchemaConverter.GenerateObjectTypeDefinitionForDatabaseObject( "table", dbObject, GenerateEmptyEntity("table"), @@ -243,7 +243,7 @@ public void SystemTypeMapsToCorrectGraphQLType(Type systemType, string graphQLTy DatabaseObject dbObject = new DatabaseTable() { TableDefinition = table }; - ObjectTypeDefinitionNode od = SchemaConverter.FromDatabaseObject( + ObjectTypeDefinitionNode od = SchemaConverter.GenerateObjectTypeDefinitionForDatabaseObject( "table", dbObject, GenerateEmptyEntity("table"), @@ -270,7 +270,7 @@ public void NullColumnBecomesNullField() DatabaseObject dbObject = new DatabaseTable() { TableDefinition = table }; - ObjectTypeDefinitionNode od = SchemaConverter.FromDatabaseObject( + ObjectTypeDefinitionNode od = SchemaConverter.GenerateObjectTypeDefinitionForDatabaseObject( "table", dbObject, GenerateEmptyEntity("table"), @@ -297,7 +297,7 @@ public void NonNullColumnBecomesNonNullField() DatabaseObject dbObject = new DatabaseTable() { TableDefinition = table }; - ObjectTypeDefinitionNode od = SchemaConverter.FromDatabaseObject( + ObjectTypeDefinitionNode od = SchemaConverter.GenerateObjectTypeDefinitionForDatabaseObject( "table", dbObject, GenerateEmptyEntity("table"), @@ -368,7 +368,7 @@ public void WhenForeignKeyDefinedButNoRelationship_GraphQLWontModelIt() DatabaseObject dbObject = new DatabaseTable() { TableDefinition = table }; ObjectTypeDefinitionNode od = - SchemaConverter.FromDatabaseObject( + SchemaConverter.GenerateObjectTypeDefinitionForDatabaseObject( SOURCE_ENTITY, dbObject, configEntity, @@ -405,7 +405,7 @@ public void SingularNamingRulesDeterminedByRuntimeConfig(string entityName, stri DatabaseObject dbObject = new DatabaseTable() { TableDefinition = table }; - ObjectTypeDefinitionNode od = SchemaConverter.FromDatabaseObject( + ObjectTypeDefinitionNode od = SchemaConverter.GenerateObjectTypeDefinitionForDatabaseObject( entityName, dbObject, configEntity, @@ -438,7 +438,7 @@ public void AutoGeneratedFieldHasDirectiveIndicatingSuch() DatabaseObject dbObject = new DatabaseTable() { TableDefinition = table }; Entity configEntity = GenerateEmptyEntity("entity"); - ObjectTypeDefinitionNode od = SchemaConverter.FromDatabaseObject( + ObjectTypeDefinitionNode od = SchemaConverter.GenerateObjectTypeDefinitionForDatabaseObject( "entity", dbObject, configEntity, @@ -489,7 +489,7 @@ public void DefaultValueGetsSetOnDirective(object defaultValue, string fieldName DatabaseObject dbObject = new DatabaseTable() { TableDefinition = table }; Entity configEntity = GenerateEmptyEntity("entity"); - ObjectTypeDefinitionNode od = SchemaConverter.FromDatabaseObject( + ObjectTypeDefinitionNode od = SchemaConverter.GenerateObjectTypeDefinitionForDatabaseObject( "entity", dbObject, configEntity, @@ -533,7 +533,7 @@ public void AutoGeneratedFieldHasAuthorizeDirective(string[] rolesForField) DatabaseObject dbObject = new DatabaseTable() { TableDefinition = table }; Entity configEntity = GenerateEmptyEntity("entity"); - ObjectTypeDefinitionNode od = SchemaConverter.FromDatabaseObject( + ObjectTypeDefinitionNode od = SchemaConverter.GenerateObjectTypeDefinitionForDatabaseObject( "entity", dbObject, configEntity, @@ -573,7 +573,7 @@ public void FieldWithAnonymousAccessHasNoAuthorizeDirective(string[] rolesForFie Entity configEntity = GenerateEmptyEntity("entity"); DatabaseObject dbObject = new DatabaseTable() { TableDefinition = table }; - ObjectTypeDefinitionNode od = SchemaConverter.FromDatabaseObject( + ObjectTypeDefinitionNode od = SchemaConverter.GenerateObjectTypeDefinitionForDatabaseObject( "entity", dbObject, configEntity, @@ -615,7 +615,7 @@ public void EntityObjectTypeDefinition_AuthorizeDirectivePresence(string[] roles DatabaseObject dbObject = new DatabaseTable() { TableDefinition = table }; Entity configEntity = GenerateEmptyEntity("entity"); - ObjectTypeDefinitionNode od = SchemaConverter.FromDatabaseObject( + ObjectTypeDefinitionNode od = SchemaConverter.GenerateObjectTypeDefinitionForDatabaseObject( "entity", dbObject, configEntity, @@ -663,7 +663,7 @@ public void EntityObjectTypeDefinition_AuthorizeDirectivePresenceMixed(string[] Entity configEntity = GenerateEmptyEntity("entity"); DatabaseObject dbObject = new DatabaseTable() { TableDefinition = table }; - ObjectTypeDefinitionNode od = SchemaConverter.FromDatabaseObject( + ObjectTypeDefinitionNode od = SchemaConverter.GenerateObjectTypeDefinitionForDatabaseObject( "entity", dbObject, configEntity, @@ -776,7 +776,7 @@ private static ObjectTypeDefinitionNode GenerateObjectWithRelationship(Cardinali DatabaseObject dbObject = new DatabaseTable() { SchemaName = SCHEMA_NAME, Name = TABLE_NAME, TableDefinition = table }; - return SchemaConverter.FromDatabaseObject( + return SchemaConverter.GenerateObjectTypeDefinitionForDatabaseObject( SOURCE_ENTITY, dbObject, configEntity, new(new Dictionary() { { TARGET_ENTITY, relationshipEntity } }), diff --git a/src/Service.Tests/GraphQLBuilder/Sql/StoredProcedureBuilderTests.cs b/src/Service.Tests/GraphQLBuilder/Sql/StoredProcedureBuilderTests.cs index 50fdb327c2..2016a3d8bb 100644 --- a/src/Service.Tests/GraphQLBuilder/Sql/StoredProcedureBuilderTests.cs +++ b/src/Service.Tests/GraphQLBuilder/Sql/StoredProcedureBuilderTests.cs @@ -197,7 +197,7 @@ public static ObjectTypeDefinitionNode CreateGraphQLTypeForEntity(Entity spEntit { // Output column metadata hydration, parameter entities is used for relationship metadata handling, which is not // relevant for stored procedure tests. - ObjectTypeDefinitionNode objectTypeDefinitionNode = SchemaConverter.FromDatabaseObject( + ObjectTypeDefinitionNode objectTypeDefinitionNode = SchemaConverter.GenerateObjectTypeDefinitionForDatabaseObject( entityName: entityName, spDbObj, configEntity: spEntity, diff --git a/src/Service.Tests/ModuleInitializer.cs b/src/Service.Tests/ModuleInitializer.cs index 1c41ec1049..5e718dc3f2 100644 --- a/src/Service.Tests/ModuleInitializer.cs +++ b/src/Service.Tests/ModuleInitializer.cs @@ -35,6 +35,8 @@ public static void Init() VerifierSettings.IgnoreMember(options => options.IsCachingEnabled); // Ignore the entity IsCachingEnabled as that's unimportant from a test standpoint. VerifierSettings.IgnoreMember(entity => entity.IsCachingEnabled); + // Ignore the entity IsLinkingEntity as that's unimportant from a test standpoint. + VerifierSettings.IgnoreMember(entity => entity.IsLinkingEntity); // Ignore the UserProvidedTtlOptions. They aren't serialized to our config file, enforced by EntityCacheOptionsConverter. VerifierSettings.IgnoreMember(cacheOptions => cacheOptions.UserProvidedTtlOptions); // Ignore the CosmosDataSourceUsed as that's unimportant from a test standpoint.