From a6da557a1b3468c866a12aeb6a1fec713403591d Mon Sep 17 00:00:00 2001
From: Ritika Dhawan <48154641+ritikadhawan@users.noreply.github.com>
Date: Tue, 18 Aug 2026 09:37:16 +0530
Subject: [PATCH 1/2] fix: don't enforce many-to-one/one-to-many relationships
for DWSQL (#3770)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## What's the problem?
When Data API builder serves a Fabric Warehouse (DWSQL) database, a
GraphQL query that follows a relationship between two tables could fail
with this error:
```
"Cannot return null for non-nullable field." (HC0018)
```
...even when the query was perfectly valid and the data was fine.
Instead of returning the row, the whole query blew up.
## Why does it happen?
Most databases guarantee that if a row points to a "parent" row (a
foreign key), that parent actually exists. DAB relies on that guarantee:
if a foreign-key column is marked "required" (NOT NULL), DAB assumes the
related object is always there, and tells GraphQL "this related field
will never be null."
**Fabric Warehouse is different — it does not enforce foreign keys.** So
a child row can happily point at a parent that doesn't exist (an
"orphaned" row). When GraphQL asks for that missing parent, DAB has
nothing to return, but it already promised the field would never be
null. GraphQL sees a broken promise and throws HC0018.
Example: an `Enrollment` row has a `studentId` that no `Student`
matches. Asking for `enrollment.student` returns nothing, and the query
fails.
## What does this change do?
For Fabric Warehouse (DWSQL) only, DAB no longer assumes related rows
always exist. It marks the related fields on many-to-one and one-to-many
relationships as nullable. That way, when a related row is genuinely
missing, the query simply returns `null` for that field instead of
failing the entire request.
Behavior for all other databases (SQL Server, PostgreSQL, MySQL) is
unchanged, since they do enforce foreign keys.
## How was it verified?
- Added unit tests covering both DWSQL (field is now nullable) and SQL
Server (field stays non-nullable) for both relationship directions.
- Manually validated end-to-end against a Fabric Warehouse: the query
that previously failed now returns the row with `student: null`.
#### Before
#### After
---------
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: aaronburtle <93220300+aaronburtle@users.noreply.github.com>
---
src/Core/Services/GraphQLSchemaCreator.cs | 1504 ++++++-------
.../Sql/SchemaConverter.cs | 1416 ++++++------
.../Sql/SchemaConverterTests.cs | 1924 +++++++++--------
3 files changed, 2437 insertions(+), 2407 deletions(-)
diff --git a/src/Core/Services/GraphQLSchemaCreator.cs b/src/Core/Services/GraphQLSchemaCreator.cs
index d449c396c0..6db4051fff 100644
--- a/src/Core/Services/GraphQLSchemaCreator.cs
+++ b/src/Core/Services/GraphQLSchemaCreator.cs
@@ -1,751 +1,753 @@
-// Copyright (c) Microsoft Corporation.
-// Licensed under the MIT License.
-
-using System.Collections.Immutable;
-using System.Collections.ObjectModel;
-using System.Net;
-using Azure.DataApiBuilder.Auth;
-using Azure.DataApiBuilder.Config;
-using Azure.DataApiBuilder.Config.DatabasePrimitives;
-using Azure.DataApiBuilder.Config.ObjectModel;
-using Azure.DataApiBuilder.Core.Configurations;
-using Azure.DataApiBuilder.Core.Resolvers.Factories;
-using Azure.DataApiBuilder.Core.Services.MetadataProviders;
-using Azure.DataApiBuilder.Service.Exceptions;
-using Azure.DataApiBuilder.Service.GraphQLBuilder;
-using Azure.DataApiBuilder.Service.GraphQLBuilder.Directives;
-using Azure.DataApiBuilder.Service.GraphQLBuilder.GraphQLTypes;
-using Azure.DataApiBuilder.Service.GraphQLBuilder.Mutations;
-using Azure.DataApiBuilder.Service.GraphQLBuilder.Queries;
-using Azure.DataApiBuilder.Service.GraphQLBuilder.Sql;
-using Azure.DataApiBuilder.Service.Services;
-using HotChocolate.Language;
-using Microsoft.Extensions.DependencyInjection;
-using static Azure.DataApiBuilder.Service.GraphQLBuilder.GraphQLNaming;
-
-namespace Azure.DataApiBuilder.Core.Services
-{
- ///
- /// Used to generate a GraphQL schema from the provided database.
- ///
- /// This will take the provided database object model for entities and
- /// combine it with the runtime configuration to apply the auth config.
- ///
- /// It also generates the middleware resolvers used for the queries
- /// and mutations, based off the provided IQueryEngine and
- /// IMutationEngine for the runtime.
- ///
- public class GraphQLSchemaCreator
- {
- private readonly IQueryEngineFactory _queryEngineFactory;
- private readonly IMutationEngineFactory _mutationEngineFactory;
- private readonly IMetadataProviderFactory _metadataProviderFactory;
- private RuntimeEntities _entities;
- private readonly IAuthorizationResolver _authorizationResolver;
- private readonly RuntimeConfigProvider _runtimeConfigProvider;
- private bool _isMultipleCreateOperationEnabled;
- private bool _isAggregationEnabled;
-
- ///
- /// Initializes a new instance of the class.
- ///
- /// Runtime config provided for the instance.
- /// QueryEngineFactory to retreive query engine to be used by resolvers.
- /// MutationEngineFactory to retreive mutation engine to be used by resolvers.
- /// MetadataProviderFactory to get metadata provider used when generating the SQL-based GraphQL schema. Ignored if the runtime is Cosmos.
- /// Authorization information for the runtime, to be applied to the GraphQL schema.
- /// Optional hot-reload event handler to subscribe to the config change event.
- public GraphQLSchemaCreator(
- RuntimeConfigProvider runtimeConfigProvider,
- IQueryEngineFactory queryEngineFactory,
- IMutationEngineFactory mutationEngineFactory,
- IMetadataProviderFactory metadataProviderFactory,
- IAuthorizationResolver authorizationResolver,
- HotReloadEventHandler? handler = null)
- {
- handler?.Subscribe(DabConfigEvents.GRAPHQL_SCHEMA_CREATOR_ON_CONFIG_CHANGED, OnConfigChanged);
- RuntimeConfig runtimeConfig = runtimeConfigProvider.GetConfig();
-
- _isMultipleCreateOperationEnabled = runtimeConfig.IsMultipleCreateOperationEnabled();
- _isAggregationEnabled = runtimeConfig.EnableAggregation;
-
- _entities = runtimeConfig.Entities;
- _queryEngineFactory = queryEngineFactory;
- _mutationEngineFactory = mutationEngineFactory;
- _metadataProviderFactory = metadataProviderFactory;
- _authorizationResolver = authorizationResolver;
- _runtimeConfigProvider = runtimeConfigProvider;
- }
-
- ///
- /// Executed when a hot-reload event occurs. Pulls the latest
- /// runtimeconfig object from the provider and updates the flag indicating
- /// whether multiple create operations are enabled, whether aggregation is enabled,
- /// and the entities based on the new config.
- ///
- protected void OnConfigChanged(object? sender, HotReloadEventArgs args)
- {
- RuntimeConfig runtimeConfig = _runtimeConfigProvider.GetConfig();
- _isMultipleCreateOperationEnabled = runtimeConfig.IsMultipleCreateOperationEnabled();
- _isAggregationEnabled = runtimeConfig.EnableAggregation;
- _entities = runtimeConfig.Entities;
- }
-
- ///
- /// Take the raw GraphQL objects and generate the full schema from them.
- /// At this point, we're somewhat agnostic to whether the runtime is Cosmos or SQL
- /// as we're working with GraphQL object types, regardless of where they came from.
- ///
- /// Schema builder
- /// Root document containing the GraphQL object and input types.
- /// Reference table of the input types for query lookup.
- private ISchemaBuilder Parse(
- ISchemaBuilder sb,
- DocumentNode root,
- Dictionary inputTypes)
- {
- // Generate the Query and the Mutation Node.
- (DocumentNode queryNode, DocumentNode mutationNode) = GenerateQueryAndMutationNodes(root, inputTypes);
-
- // Hot Chocolate v16 validates schemas eagerly during host startup
- // (RequestExecutorWarmupService) and rejects an empty Query type with
- // "The object type `Query` has to at least define one field in order to be valid."
- // This can occur in valid runtime configurations: GraphQL globally disabled,
- // every entity opting out of GraphQL via `graphql.enabled = false`, or no entities
- // configured. Inject a hidden placeholder field with a no-op resolver so the schema
- // is structurally valid; HC v16 also rejects fields without resolvers.
- queryNode = EnsureQueryHasAtLeastOneField(queryNode, sb);
-
- return sb
- .AddDocument(root)
- .AddAuthorizeDirectiveType()
- // Add our custom directives
- .AddType()
- .AddDirectiveType()
- .AddDirectiveType()
- .AddDirectiveType()
- .AddDirectiveType()
- .AddDirectiveType()
- // Add our custom scalar GraphQL types
- .AddType()
- .AddType()
- // Generate the GraphQL queries from the provided objects
- .AddDocument(queryNode)
- // Generate the GraphQL mutations from the provided objects
- .AddDocument(mutationNode)
- // Adds our type interceptor that will create the resolvers.
- .TryAddTypeInterceptor(new ResolverTypeInterceptor(new ExecutionHelper(_queryEngineFactory, _mutationEngineFactory, _runtimeConfigProvider)));
- }
-
- ///
- /// Name of the hidden placeholder field added to Query when no entity contributes
- /// a query field, used to keep the schema valid for HC v16's eager validation.
- ///
- internal const string EMPTY_SCHEMA_PLACEHOLDER_FIELD_NAME = "_dab";
-
- ///
- /// If the generated Query object type has no fields, append a hidden placeholder
- /// field and register a null-returning resolver for it. The placeholder is shadowed in
- /// any configuration that produces real query fields, so it is only visible in
- /// otherwise-empty schemas (GraphQL globally disabled, all entities opting out,
- /// no entities configured).
- ///
- ///
- /// Marked internal rather than private so the test project (granted access
- /// via InternalsVisibleTo in SqlMetadataProvider.cs) can exercise the rewrite
- /// logic directly without spinning up the full schema builder pipeline.
- ///
- internal static DocumentNode EnsureQueryHasAtLeastOneField(DocumentNode queryNode, ISchemaBuilder sb)
- {
- // Locate the empty Query definition, if any. HotChocolate's DocumentNode exposes
- // Definitions as an ordered IReadOnlyList with no by-name index, so this is the
- // cheapest available lookup. Common case: Query has fields and we return unchanged
- // without allocating a new definitions list.
- int emptyQueryIndex = -1;
- for (int i = 0; i < queryNode.Definitions.Count; i++)
- {
- if (queryNode.Definitions[i] is ObjectTypeDefinitionNode { Name.Value: "Query", Fields.Count: 0 })
- {
- emptyQueryIndex = i;
- break;
- }
- }
-
- if (emptyQueryIndex < 0)
- {
- return queryNode;
- }
-
- ObjectTypeDefinitionNode emptyQuery = (ObjectTypeDefinitionNode)queryNode.Definitions[emptyQueryIndex];
-
- FieldDefinitionNode placeholderField = new(
- location: null,
- new NameNode(EMPTY_SCHEMA_PLACEHOLDER_FIELD_NAME),
- new StringValueNode(
- "Internal placeholder; only present when no entity contributes a query field. "
- + "Always returns null and is never reachable in normal operation."),
- arguments: new List(),
- type: new NamedTypeNode(new NameNode("String")),
- directives: new List());
-
- ObjectTypeDefinitionNode rewrittenQuery = new(
- emptyQuery.Location,
- emptyQuery.Name,
- emptyQuery.Description,
- emptyQuery.Directives,
- emptyQuery.Interfaces,
- new List { placeholderField });
-
- // HC v16 requires every field to have a resolver; bind a no-op that always
- // returns null. The field is unreachable in normal operation because callers
- // for empty-Query configurations never issue GraphQL requests.
- sb.AddResolver("Query", EMPTY_SCHEMA_PLACEHOLDER_FIELD_NAME, _ => null);
-
- IDefinitionNode[] newDefinitions = new IDefinitionNode[queryNode.Definitions.Count];
- for (int i = 0; i < queryNode.Definitions.Count; i++)
- {
- newDefinitions[i] = i == emptyQueryIndex ? rewrittenQuery : queryNode.Definitions[i];
- }
-
- return new DocumentNode(newDefinitions);
- }
-
- ///
- /// Generate the GraphQL schema query and mutation nodes from the provided database.
- ///
- /// Root document node which contains base entity types.
- /// Dictionary with key being the object and value the input object type definition node for that object.
- /// Query and mutation nodes.
- public (DocumentNode, DocumentNode) GenerateQueryAndMutationNodes(DocumentNode root, Dictionary inputTypes)
- {
- Dictionary entityToDbObjects = new();
- Dictionary entityToDatabaseType = new();
-
- HashSet dataSourceNames = new();
-
- // Merge the entityToDBObjects for queryNode generation for all entities.
- foreach ((string entityName, _) in _entities)
- {
- string dataSourceName = _runtimeConfigProvider.GetConfig().GetDataSourceNameFromEntityName(entityName);
- ISqlMetadataProvider metadataProvider = _metadataProviderFactory.GetMetadataProvider(dataSourceName);
- if (!dataSourceNames.Contains(dataSourceName))
- {
- entityToDbObjects = entityToDbObjects.Concat(metadataProvider.EntityToDatabaseObject).ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
- dataSourceNames.Add(dataSourceName);
- }
-
- entityToDatabaseType.TryAdd(entityName, metadataProvider.GetDatabaseType());
- }
- // Generate the GraphQL queries from the provided objects
- DocumentNode queryNode = QueryBuilder.Build(root, entityToDatabaseType, _entities, inputTypes, _authorizationResolver.EntityPermissionsMap, entityToDbObjects, _isAggregationEnabled);
-
- // Generate the GraphQL mutations from the provided objects
- DocumentNode mutationNode = MutationBuilder.Build(root, entityToDatabaseType, _entities, _authorizationResolver.EntityPermissionsMap, entityToDbObjects, _isMultipleCreateOperationEnabled);
-
- return (queryNode, mutationNode);
- }
-
- ///
- /// If the metastore provider is able to get the graphql schema,
- /// this function parses it and attaches resolvers to the various query fields.
- ///
- /// Thrown if the database type is not supported
- /// The ISchemaBuilder for HotChocolate, with the generated GraphQL schema
- public ISchemaBuilder InitializeSchemaAndResolvers(ISchemaBuilder schemaBuilder)
- {
- (DocumentNode root, Dictionary inputTypes) = GenerateGraphQLObjects();
- return Parse(schemaBuilder, root, inputTypes);
- }
-
- ///
- /// Generates the ObjectTypeDefinitionNodes and InputObjectTypeDefinitionNodes as part of GraphQL Schema generation
- /// with the provided entities listed in the runtime configuration that match the provided database type.
- ///
- /// Key/Value Collection {entityName -> Entity object}
- /// Root GraphQLSchema DocumentNode and inputNodes to be processed by downstream schema generation helpers.
- ///
- 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();
-
- Dictionary enumTypes = new();
-
- // 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)
- {
- continue;
- }
-
- if (sqlMetadataProvider.GetEntityNamesAndDbObjects().TryGetValue(entityName, out DatabaseObject? databaseObject))
- {
- // Collection of role names allowed to access entity, to be added to the authorize directive
- // of the objectTypeDefinitionNode. The authorize Directive is one of many directives created.
- IEnumerable rolesAllowedForEntity = _authorizationResolver.GetRolesForEntity(entityName);
- Dictionary> rolesAllowedForFields = new();
- SourceDefinition sourceDefinition = sqlMetadataProvider.GetSourceDefinition(entityName);
- bool isStoredProcedure = entity.Source.Type is EntitySourceType.StoredProcedure;
- EntityActionOperation operation = isStoredProcedure ? EntityActionOperation.Execute : EntityActionOperation.Read;
- foreach (string column in sourceDefinition.Columns.Keys)
- {
- IEnumerable roles = _authorizationResolver.GetRolesForField(entityName, field: column, operation: operation);
- if (!rolesAllowedForFields.TryAdd(key: column, value: roles))
- {
- throw new DataApiBuilderException(
- message: "Column already processed for building ObjectTypeDefinition authorization definition.",
- statusCode: HttpStatusCode.InternalServerError,
- subStatusCode: DataApiBuilderException.SubStatusCodes.ErrorInInitialization
- );
- }
- }
-
- // Only add objectTypeDefinition for GraphQL if it has a role definition defined for access.
- if (rolesAllowedForEntity.Any())
- {
- ObjectTypeDefinitionNode node = SchemaConverter.GenerateObjectTypeDefinitionForDatabaseObject(
- entityName: entityName,
- databaseObject: databaseObject,
- configEntity: entity,
- entities: entities,
- rolesAllowedForEntity: rolesAllowedForEntity,
- rolesAllowedForFields: rolesAllowedForFields);
-
- if (databaseObject.SourceType is not EntitySourceType.StoredProcedure)
- {
- InputTypeBuilder.GenerateInputTypesForObjectType(node, inputObjects);
-
- if (_isAggregationEnabled)
- {
- bool isAggregationEnumCreated = EnumTypeBuilder.GenerateAggregationNumericEnumForObjectType(node, enumTypes);
- bool isGroupByColumnsEnumCreated = EnumTypeBuilder.GenerateScalarFieldsEnumForObjectType(node, enumTypes);
- ObjectTypeDefinitionNode aggregationType;
- ObjectTypeDefinitionNode groupByEntityNode;
-
- // note: if aggregation enum is created, groupByColumnsEnum is also created as there would be scalar fields to groupby.
- if (isAggregationEnumCreated)
- {
- // Both aggregation and group by columns enum types are created for the entity. GroupBy should include fields and aggregation subfields.
- aggregationType = SchemaConverter.GenerateAggregationTypeForEntity(node.Name.Value, node);
- groupByEntityNode = SchemaConverter.GenerateGroupByTypeForEntity(node.Name.Value, node);
- IReadOnlyList groupByFields = groupByEntityNode.Fields;
- string aggregationsTypeName = SchemaConverter.GenerateObjectAggregationNodeName(node.Name.Value);
- FieldDefinitionNode aggregationNode = new(
- location: null,
- name: new NameNode(QueryBuilder.GROUP_BY_AGGREGATE_FIELD_NAME),
- description: new StringValueNode($"Aggregations for {entityName}"),
- arguments: new List(),
- type: new NamedTypeNode(new NameNode(aggregationsTypeName)),
- directives: new List()
- );
- List fieldDefinitionNodes = new(groupByFields) { aggregationNode };
- groupByEntityNode = groupByEntityNode.WithFields(fieldDefinitionNodes);
- objectTypes.Add(SchemaConverter.GenerateObjectAggregationNodeName(entityName), aggregationType);
- objectTypes.Add(SchemaConverter.GenerateGroupByTypeName(entityName), groupByEntityNode);
- }
- else if (isGroupByColumnsEnumCreated)
- {
- // only groupBy enum is created for the entity. GroupBy should include fields but not aggregations.
- groupByEntityNode = SchemaConverter.GenerateGroupByTypeForEntity(entityName, node);
- objectTypes.Add(SchemaConverter.GenerateGroupByTypeName(entityName), groupByEntityNode);
- }
- }
- }
-
- objectTypes.Add(entityName, node);
- }
- }
- else
- {
- throw new DataApiBuilderException(message: $"Database Object definition for {entityName} has not been inferred.",
- statusCode: HttpStatusCode.InternalServerError,
- subStatusCode: DataApiBuilderException.SubStatusCodes.ErrorInInitialization);
- }
- }
-
- // ReferencingFieldDirective is added to eventually mark the referencing fields in the input object types as optional. When multiple create operations are disabled
- // the referencing fields should be required fields. Hence, ReferencingFieldDirective is added only when the multiple create operations are enabled.
- if (_isMultipleCreateOperationEnabled)
- {
- // 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.
- // However, ObjectTypeDefinitionNode for linking entities are need only for multiple create operation. So, creating these only when multiple create operations are
- // enabled.
- if (_isMultipleCreateOperationEnabled)
- {
- Dictionary linkingObjectTypes = GenerateObjectDefinitionsForLinkingEntities();
- GenerateSourceTargetLinkingObjectDefinitions(objectTypes, linkingObjectTypes);
- }
-
- NameNode nameNode = new(value: GraphQLUtils.DB_OPERATION_RESULT_TYPE);
-
- // Add the DBOperationResult type to the schema
- objectTypes.Add(GraphQLUtils.DB_OPERATION_RESULT_TYPE, new ObjectTypeDefinitionNode(
- location: null,
- name: nameNode,
- description: null,
- new List(),
- new List(),
- ImmutableList.Create(GetDbOperationResultField())));
-
- // Return a list of all the object types to be exposed in the schema.
- List nodes = new(objectTypes.Values);
- nodes.AddRange(enumTypes.Values);
- 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.
- /// NOTE: DataSourceNames must be preFiltered to be cosmos datasources.
- ///
- /// Hashset of datasourceNames to generate cosmos objects.
- private DocumentNode GenerateCosmosGraphQLObjects(HashSet dataSourceNames, Dictionary inputObjects)
- {
- DocumentNode? root = null;
-
- if (dataSourceNames.Count() == 0)
- {
- return new DocumentNode(new List());
- }
-
- foreach (string dataSourceName in dataSourceNames)
- {
- ISqlMetadataProvider metadataProvider = _metadataProviderFactory.GetMetadataProvider(dataSourceName);
- DocumentNode currentNode = ((CosmosSqlMetadataProvider)metadataProvider).GraphQLSchemaRoot;
- root = root is null ? currentNode : root.WithDefinitions(root.Definitions.Concat(currentNode.Definitions).ToImmutableList());
- }
-
- IEnumerable objectNodes = root!.Definitions.Where(d => d is ObjectTypeDefinitionNode).Cast();
- foreach (ObjectTypeDefinitionNode node in objectNodes)
- {
- InputTypeBuilder.GenerateInputTypesForObjectType(node, inputObjects);
- }
-
- return root;
- }
-
- ///
- /// Create and return a default GraphQL result field for a mutation which doesn't
- /// define a result set and doesn't return any rows.
- ///
- private static FieldDefinitionNode GetDbOperationResultField()
- {
- return new(
- location: null,
- name: new(GraphQLUtils.DB_OPERATION_RESULT_FIELD_NAME),
- description: new StringValueNode("Contains result for mutation execution"),
- arguments: new List(),
- type: new StringType().ToTypeNode(),
- directives: new List());
- }
-
- public (DocumentNode, Dictionary) GenerateGraphQLObjects()
- {
- RuntimeConfig runtimeConfig = _runtimeConfigProvider.GetConfig();
- HashSet cosmosDataSourceNames = new();
- IDictionary sqlEntities = new Dictionary();
- Dictionary inputObjects = new();
-
- foreach ((string entityName, Entity entity) in runtimeConfig.Entities)
- {
- DataSource ds = runtimeConfig.GetDataSourceFromEntityName(entityName);
-
- switch (ds.DatabaseType)
- {
- case DatabaseType.CosmosDB_NoSQL:
- cosmosDataSourceNames.Add(_runtimeConfigProvider.GetConfig().GetDataSourceNameFromEntityName(entityName));
- break;
- case DatabaseType.MSSQL or DatabaseType.MySQL or DatabaseType.PostgreSQL or DatabaseType.DWSQL:
- sqlEntities.TryAdd(entityName, entity);
- break;
- default:
- throw new NotImplementedException($"This database type {ds.DatabaseType} is not yet implemented.");
- }
- }
-
- RuntimeEntities sql = new(new ReadOnlyDictionary(sqlEntities));
-
- DocumentNode cosmosResult = GenerateCosmosGraphQLObjects(cosmosDataSourceNames, inputObjects);
- DocumentNode sqlResult = GenerateSqlGraphQLObjects(sql, inputObjects);
- // Create Root node with definitions from both cosmos and sql.
- DocumentNode root = cosmosResult.WithDefinitions(cosmosResult.Definitions.Concat(sqlResult.Definitions).ToImmutableList());
-
- // Merge the inputobjectType definitions from cosmos and sql onto the root.
- return (root.WithDefinitions(root.Definitions.Concat(inputObjects.Values).ToImmutableList()), inputObjects);
- }
- }
-}
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Collections.Immutable;
+using System.Collections.ObjectModel;
+using System.Net;
+using Azure.DataApiBuilder.Auth;
+using Azure.DataApiBuilder.Config;
+using Azure.DataApiBuilder.Config.DatabasePrimitives;
+using Azure.DataApiBuilder.Config.ObjectModel;
+using Azure.DataApiBuilder.Core.Configurations;
+using Azure.DataApiBuilder.Core.Resolvers.Factories;
+using Azure.DataApiBuilder.Core.Services.MetadataProviders;
+using Azure.DataApiBuilder.Service.Exceptions;
+using Azure.DataApiBuilder.Service.GraphQLBuilder;
+using Azure.DataApiBuilder.Service.GraphQLBuilder.Directives;
+using Azure.DataApiBuilder.Service.GraphQLBuilder.GraphQLTypes;
+using Azure.DataApiBuilder.Service.GraphQLBuilder.Mutations;
+using Azure.DataApiBuilder.Service.GraphQLBuilder.Queries;
+using Azure.DataApiBuilder.Service.GraphQLBuilder.Sql;
+using Azure.DataApiBuilder.Service.Services;
+using HotChocolate.Language;
+using Microsoft.Extensions.DependencyInjection;
+using static Azure.DataApiBuilder.Service.GraphQLBuilder.GraphQLNaming;
+
+namespace Azure.DataApiBuilder.Core.Services
+{
+ ///
+ /// Used to generate a GraphQL schema from the provided database.
+ ///
+ /// This will take the provided database object model for entities and
+ /// combine it with the runtime configuration to apply the auth config.
+ ///
+ /// It also generates the middleware resolvers used for the queries
+ /// and mutations, based off the provided IQueryEngine and
+ /// IMutationEngine for the runtime.
+ ///
+ public class GraphQLSchemaCreator
+ {
+ private readonly IQueryEngineFactory _queryEngineFactory;
+ private readonly IMutationEngineFactory _mutationEngineFactory;
+ private readonly IMetadataProviderFactory _metadataProviderFactory;
+ private RuntimeEntities _entities;
+ private readonly IAuthorizationResolver _authorizationResolver;
+ private readonly RuntimeConfigProvider _runtimeConfigProvider;
+ private bool _isMultipleCreateOperationEnabled;
+ private bool _isAggregationEnabled;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Runtime config provided for the instance.
+ /// QueryEngineFactory to retreive query engine to be used by resolvers.
+ /// MutationEngineFactory to retreive mutation engine to be used by resolvers.
+ /// MetadataProviderFactory to get metadata provider used when generating the SQL-based GraphQL schema. Ignored if the runtime is Cosmos.
+ /// Authorization information for the runtime, to be applied to the GraphQL schema.
+ /// Optional hot-reload event handler to subscribe to the config change event.
+ public GraphQLSchemaCreator(
+ RuntimeConfigProvider runtimeConfigProvider,
+ IQueryEngineFactory queryEngineFactory,
+ IMutationEngineFactory mutationEngineFactory,
+ IMetadataProviderFactory metadataProviderFactory,
+ IAuthorizationResolver authorizationResolver,
+ HotReloadEventHandler? handler = null)
+ {
+ handler?.Subscribe(DabConfigEvents.GRAPHQL_SCHEMA_CREATOR_ON_CONFIG_CHANGED, OnConfigChanged);
+ RuntimeConfig runtimeConfig = runtimeConfigProvider.GetConfig();
+
+ _isMultipleCreateOperationEnabled = runtimeConfig.IsMultipleCreateOperationEnabled();
+ _isAggregationEnabled = runtimeConfig.EnableAggregation;
+
+ _entities = runtimeConfig.Entities;
+ _queryEngineFactory = queryEngineFactory;
+ _mutationEngineFactory = mutationEngineFactory;
+ _metadataProviderFactory = metadataProviderFactory;
+ _authorizationResolver = authorizationResolver;
+ _runtimeConfigProvider = runtimeConfigProvider;
+ }
+
+ ///
+ /// Executed when a hot-reload event occurs. Pulls the latest
+ /// runtimeconfig object from the provider and updates the flag indicating
+ /// whether multiple create operations are enabled, whether aggregation is enabled,
+ /// and the entities based on the new config.
+ ///
+ protected void OnConfigChanged(object? sender, HotReloadEventArgs args)
+ {
+ RuntimeConfig runtimeConfig = _runtimeConfigProvider.GetConfig();
+ _isMultipleCreateOperationEnabled = runtimeConfig.IsMultipleCreateOperationEnabled();
+ _isAggregationEnabled = runtimeConfig.EnableAggregation;
+ _entities = runtimeConfig.Entities;
+ }
+
+ ///
+ /// Take the raw GraphQL objects and generate the full schema from them.
+ /// At this point, we're somewhat agnostic to whether the runtime is Cosmos or SQL
+ /// as we're working with GraphQL object types, regardless of where they came from.
+ ///
+ /// Schema builder
+ /// Root document containing the GraphQL object and input types.
+ /// Reference table of the input types for query lookup.
+ private ISchemaBuilder Parse(
+ ISchemaBuilder sb,
+ DocumentNode root,
+ Dictionary inputTypes)
+ {
+ // Generate the Query and the Mutation Node.
+ (DocumentNode queryNode, DocumentNode mutationNode) = GenerateQueryAndMutationNodes(root, inputTypes);
+
+ // Hot Chocolate v16 validates schemas eagerly during host startup
+ // (RequestExecutorWarmupService) and rejects an empty Query type with
+ // "The object type `Query` has to at least define one field in order to be valid."
+ // This can occur in valid runtime configurations: GraphQL globally disabled,
+ // every entity opting out of GraphQL via `graphql.enabled = false`, or no entities
+ // configured. Inject a hidden placeholder field with a no-op resolver so the schema
+ // is structurally valid; HC v16 also rejects fields without resolvers.
+ queryNode = EnsureQueryHasAtLeastOneField(queryNode, sb);
+
+ return sb
+ .AddDocument(root)
+ .AddAuthorizeDirectiveType()
+ // Add our custom directives
+ .AddType()
+ .AddDirectiveType()
+ .AddDirectiveType()
+ .AddDirectiveType()
+ .AddDirectiveType()
+ .AddDirectiveType()
+ // Add our custom scalar GraphQL types
+ .AddType()
+ .AddType()
+ // Generate the GraphQL queries from the provided objects
+ .AddDocument(queryNode)
+ // Generate the GraphQL mutations from the provided objects
+ .AddDocument(mutationNode)
+ // Adds our type interceptor that will create the resolvers.
+ .TryAddTypeInterceptor(new ResolverTypeInterceptor(new ExecutionHelper(_queryEngineFactory, _mutationEngineFactory, _runtimeConfigProvider)));
+ }
+
+ ///
+ /// Name of the hidden placeholder field added to Query when no entity contributes
+ /// a query field, used to keep the schema valid for HC v16's eager validation.
+ ///
+ internal const string EMPTY_SCHEMA_PLACEHOLDER_FIELD_NAME = "_dab";
+
+ ///
+ /// If the generated Query object type has no fields, append a hidden placeholder
+ /// field and register a null-returning resolver for it. The placeholder is shadowed in
+ /// any configuration that produces real query fields, so it is only visible in
+ /// otherwise-empty schemas (GraphQL globally disabled, all entities opting out,
+ /// no entities configured).
+ ///
+ ///
+ /// Marked internal rather than private so the test project (granted access
+ /// via InternalsVisibleTo in SqlMetadataProvider.cs) can exercise the rewrite
+ /// logic directly without spinning up the full schema builder pipeline.
+ ///
+ internal static DocumentNode EnsureQueryHasAtLeastOneField(DocumentNode queryNode, ISchemaBuilder sb)
+ {
+ // Locate the empty Query definition, if any. HotChocolate's DocumentNode exposes
+ // Definitions as an ordered IReadOnlyList with no by-name index, so this is the
+ // cheapest available lookup. Common case: Query has fields and we return unchanged
+ // without allocating a new definitions list.
+ int emptyQueryIndex = -1;
+ for (int i = 0; i < queryNode.Definitions.Count; i++)
+ {
+ if (queryNode.Definitions[i] is ObjectTypeDefinitionNode { Name.Value: "Query", Fields.Count: 0 })
+ {
+ emptyQueryIndex = i;
+ break;
+ }
+ }
+
+ if (emptyQueryIndex < 0)
+ {
+ return queryNode;
+ }
+
+ ObjectTypeDefinitionNode emptyQuery = (ObjectTypeDefinitionNode)queryNode.Definitions[emptyQueryIndex];
+
+ FieldDefinitionNode placeholderField = new(
+ location: null,
+ new NameNode(EMPTY_SCHEMA_PLACEHOLDER_FIELD_NAME),
+ new StringValueNode(
+ "Internal placeholder; only present when no entity contributes a query field. "
+ + "Always returns null and is never reachable in normal operation."),
+ arguments: new List(),
+ type: new NamedTypeNode(new NameNode("String")),
+ directives: new List());
+
+ ObjectTypeDefinitionNode rewrittenQuery = new(
+ emptyQuery.Location,
+ emptyQuery.Name,
+ emptyQuery.Description,
+ emptyQuery.Directives,
+ emptyQuery.Interfaces,
+ new List { placeholderField });
+
+ // HC v16 requires every field to have a resolver; bind a no-op that always
+ // returns null. The field is unreachable in normal operation because callers
+ // for empty-Query configurations never issue GraphQL requests.
+ sb.AddResolver("Query", EMPTY_SCHEMA_PLACEHOLDER_FIELD_NAME, _ => null);
+
+ IDefinitionNode[] newDefinitions = new IDefinitionNode[queryNode.Definitions.Count];
+ for (int i = 0; i < queryNode.Definitions.Count; i++)
+ {
+ newDefinitions[i] = i == emptyQueryIndex ? rewrittenQuery : queryNode.Definitions[i];
+ }
+
+ return new DocumentNode(newDefinitions);
+ }
+
+ ///
+ /// Generate the GraphQL schema query and mutation nodes from the provided database.
+ ///
+ /// Root document node which contains base entity types.
+ /// Dictionary with key being the object and value the input object type definition node for that object.
+ /// Query and mutation nodes.
+ public (DocumentNode, DocumentNode) GenerateQueryAndMutationNodes(DocumentNode root, Dictionary inputTypes)
+ {
+ Dictionary entityToDbObjects = new();
+ Dictionary entityToDatabaseType = new();
+
+ HashSet dataSourceNames = new();
+
+ // Merge the entityToDBObjects for queryNode generation for all entities.
+ foreach ((string entityName, _) in _entities)
+ {
+ string dataSourceName = _runtimeConfigProvider.GetConfig().GetDataSourceNameFromEntityName(entityName);
+ ISqlMetadataProvider metadataProvider = _metadataProviderFactory.GetMetadataProvider(dataSourceName);
+ if (!dataSourceNames.Contains(dataSourceName))
+ {
+ entityToDbObjects = entityToDbObjects.Concat(metadataProvider.EntityToDatabaseObject).ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
+ dataSourceNames.Add(dataSourceName);
+ }
+
+ entityToDatabaseType.TryAdd(entityName, metadataProvider.GetDatabaseType());
+ }
+ // Generate the GraphQL queries from the provided objects
+ DocumentNode queryNode = QueryBuilder.Build(root, entityToDatabaseType, _entities, inputTypes, _authorizationResolver.EntityPermissionsMap, entityToDbObjects, _isAggregationEnabled);
+
+ // Generate the GraphQL mutations from the provided objects
+ DocumentNode mutationNode = MutationBuilder.Build(root, entityToDatabaseType, _entities, _authorizationResolver.EntityPermissionsMap, entityToDbObjects, _isMultipleCreateOperationEnabled);
+
+ return (queryNode, mutationNode);
+ }
+
+ ///
+ /// If the metastore provider is able to get the graphql schema,
+ /// this function parses it and attaches resolvers to the various query fields.
+ ///
+ /// Thrown if the database type is not supported
+ /// The ISchemaBuilder for HotChocolate, with the generated GraphQL schema
+ public ISchemaBuilder InitializeSchemaAndResolvers(ISchemaBuilder schemaBuilder)
+ {
+ (DocumentNode root, Dictionary inputTypes) = GenerateGraphQLObjects();
+ return Parse(schemaBuilder, root, inputTypes);
+ }
+
+ ///
+ /// Generates the ObjectTypeDefinitionNodes and InputObjectTypeDefinitionNodes as part of GraphQL Schema generation
+ /// with the provided entities listed in the runtime configuration that match the provided database type.
+ ///
+ /// Key/Value Collection {entityName -> Entity object}
+ /// Root GraphQLSchema DocumentNode and inputNodes to be processed by downstream schema generation helpers.
+ ///
+ 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();
+
+ Dictionary enumTypes = new();
+
+ // 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)
+ {
+ continue;
+ }
+
+ if (sqlMetadataProvider.GetEntityNamesAndDbObjects().TryGetValue(entityName, out DatabaseObject? databaseObject))
+ {
+ // Collection of role names allowed to access entity, to be added to the authorize directive
+ // of the objectTypeDefinitionNode. The authorize Directive is one of many directives created.
+ IEnumerable rolesAllowedForEntity = _authorizationResolver.GetRolesForEntity(entityName);
+ Dictionary> rolesAllowedForFields = new();
+ SourceDefinition sourceDefinition = sqlMetadataProvider.GetSourceDefinition(entityName);
+ bool isStoredProcedure = entity.Source.Type is EntitySourceType.StoredProcedure;
+ EntityActionOperation operation = isStoredProcedure ? EntityActionOperation.Execute : EntityActionOperation.Read;
+ foreach (string column in sourceDefinition.Columns.Keys)
+ {
+ IEnumerable roles = _authorizationResolver.GetRolesForField(entityName, field: column, operation: operation);
+ if (!rolesAllowedForFields.TryAdd(key: column, value: roles))
+ {
+ throw new DataApiBuilderException(
+ message: "Column already processed for building ObjectTypeDefinition authorization definition.",
+ statusCode: HttpStatusCode.InternalServerError,
+ subStatusCode: DataApiBuilderException.SubStatusCodes.ErrorInInitialization
+ );
+ }
+ }
+
+ // Only add objectTypeDefinition for GraphQL if it has a role definition defined for access.
+ if (rolesAllowedForEntity.Any())
+ {
+ ObjectTypeDefinitionNode node = SchemaConverter.GenerateObjectTypeDefinitionForDatabaseObject(
+ entityName: entityName,
+ databaseObject: databaseObject,
+ configEntity: entity,
+ entities: entities,
+ rolesAllowedForEntity: rolesAllowedForEntity,
+ rolesAllowedForFields: rolesAllowedForFields,
+ databaseType: sqlMetadataProvider.GetDatabaseType());
+
+ if (databaseObject.SourceType is not EntitySourceType.StoredProcedure)
+ {
+ InputTypeBuilder.GenerateInputTypesForObjectType(node, inputObjects);
+
+ if (_isAggregationEnabled)
+ {
+ bool isAggregationEnumCreated = EnumTypeBuilder.GenerateAggregationNumericEnumForObjectType(node, enumTypes);
+ bool isGroupByColumnsEnumCreated = EnumTypeBuilder.GenerateScalarFieldsEnumForObjectType(node, enumTypes);
+ ObjectTypeDefinitionNode aggregationType;
+ ObjectTypeDefinitionNode groupByEntityNode;
+
+ // note: if aggregation enum is created, groupByColumnsEnum is also created as there would be scalar fields to groupby.
+ if (isAggregationEnumCreated)
+ {
+ // Both aggregation and group by columns enum types are created for the entity. GroupBy should include fields and aggregation subfields.
+ aggregationType = SchemaConverter.GenerateAggregationTypeForEntity(node.Name.Value, node);
+ groupByEntityNode = SchemaConverter.GenerateGroupByTypeForEntity(node.Name.Value, node);
+ IReadOnlyList groupByFields = groupByEntityNode.Fields;
+ string aggregationsTypeName = SchemaConverter.GenerateObjectAggregationNodeName(node.Name.Value);
+ FieldDefinitionNode aggregationNode = new(
+ location: null,
+ name: new NameNode(QueryBuilder.GROUP_BY_AGGREGATE_FIELD_NAME),
+ description: new StringValueNode($"Aggregations for {entityName}"),
+ arguments: new List(),
+ type: new NamedTypeNode(new NameNode(aggregationsTypeName)),
+ directives: new List()
+ );
+ List fieldDefinitionNodes = new(groupByFields) { aggregationNode };
+ groupByEntityNode = groupByEntityNode.WithFields(fieldDefinitionNodes);
+ objectTypes.Add(SchemaConverter.GenerateObjectAggregationNodeName(entityName), aggregationType);
+ objectTypes.Add(SchemaConverter.GenerateGroupByTypeName(entityName), groupByEntityNode);
+ }
+ else if (isGroupByColumnsEnumCreated)
+ {
+ // only groupBy enum is created for the entity. GroupBy should include fields but not aggregations.
+ groupByEntityNode = SchemaConverter.GenerateGroupByTypeForEntity(entityName, node);
+ objectTypes.Add(SchemaConverter.GenerateGroupByTypeName(entityName), groupByEntityNode);
+ }
+ }
+ }
+
+ objectTypes.Add(entityName, node);
+ }
+ }
+ else
+ {
+ throw new DataApiBuilderException(message: $"Database Object definition for {entityName} has not been inferred.",
+ statusCode: HttpStatusCode.InternalServerError,
+ subStatusCode: DataApiBuilderException.SubStatusCodes.ErrorInInitialization);
+ }
+ }
+
+ // ReferencingFieldDirective is added to eventually mark the referencing fields in the input object types as optional. When multiple create operations are disabled
+ // the referencing fields should be required fields. Hence, ReferencingFieldDirective is added only when the multiple create operations are enabled.
+ if (_isMultipleCreateOperationEnabled)
+ {
+ // 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.
+ // However, ObjectTypeDefinitionNode for linking entities are need only for multiple create operation. So, creating these only when multiple create operations are
+ // enabled.
+ if (_isMultipleCreateOperationEnabled)
+ {
+ Dictionary linkingObjectTypes = GenerateObjectDefinitionsForLinkingEntities();
+ GenerateSourceTargetLinkingObjectDefinitions(objectTypes, linkingObjectTypes);
+ }
+
+ NameNode nameNode = new(value: GraphQLUtils.DB_OPERATION_RESULT_TYPE);
+
+ // Add the DBOperationResult type to the schema
+ objectTypes.Add(GraphQLUtils.DB_OPERATION_RESULT_TYPE, new ObjectTypeDefinitionNode(
+ location: null,
+ name: nameNode,
+ description: null,
+ new List(),
+ new List(),
+ ImmutableList.Create(GetDbOperationResultField())));
+
+ // Return a list of all the object types to be exposed in the schema.
+ List nodes = new(objectTypes.Values);
+ nodes.AddRange(enumTypes.Values);
+ 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>(),
+ databaseType: sqlMetadataProvider.GetDatabaseType()
+ );
+
+ 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.
+ /// NOTE: DataSourceNames must be preFiltered to be cosmos datasources.
+ ///
+ /// Hashset of datasourceNames to generate cosmos objects.
+ private DocumentNode GenerateCosmosGraphQLObjects(HashSet dataSourceNames, Dictionary inputObjects)
+ {
+ DocumentNode? root = null;
+
+ if (dataSourceNames.Count() == 0)
+ {
+ return new DocumentNode(new List());
+ }
+
+ foreach (string dataSourceName in dataSourceNames)
+ {
+ ISqlMetadataProvider metadataProvider = _metadataProviderFactory.GetMetadataProvider(dataSourceName);
+ DocumentNode currentNode = ((CosmosSqlMetadataProvider)metadataProvider).GraphQLSchemaRoot;
+ root = root is null ? currentNode : root.WithDefinitions(root.Definitions.Concat(currentNode.Definitions).ToImmutableList());
+ }
+
+ IEnumerable objectNodes = root!.Definitions.Where(d => d is ObjectTypeDefinitionNode).Cast();
+ foreach (ObjectTypeDefinitionNode node in objectNodes)
+ {
+ InputTypeBuilder.GenerateInputTypesForObjectType(node, inputObjects);
+ }
+
+ return root;
+ }
+
+ ///
+ /// Create and return a default GraphQL result field for a mutation which doesn't
+ /// define a result set and doesn't return any rows.
+ ///
+ private static FieldDefinitionNode GetDbOperationResultField()
+ {
+ return new(
+ location: null,
+ name: new(GraphQLUtils.DB_OPERATION_RESULT_FIELD_NAME),
+ description: new StringValueNode("Contains result for mutation execution"),
+ arguments: new List(),
+ type: new StringType().ToTypeNode(),
+ directives: new List());
+ }
+
+ public (DocumentNode, Dictionary) GenerateGraphQLObjects()
+ {
+ RuntimeConfig runtimeConfig = _runtimeConfigProvider.GetConfig();
+ HashSet cosmosDataSourceNames = new();
+ IDictionary sqlEntities = new Dictionary();
+ Dictionary inputObjects = new();
+
+ foreach ((string entityName, Entity entity) in runtimeConfig.Entities)
+ {
+ DataSource ds = runtimeConfig.GetDataSourceFromEntityName(entityName);
+
+ switch (ds.DatabaseType)
+ {
+ case DatabaseType.CosmosDB_NoSQL:
+ cosmosDataSourceNames.Add(_runtimeConfigProvider.GetConfig().GetDataSourceNameFromEntityName(entityName));
+ break;
+ case DatabaseType.MSSQL or DatabaseType.MySQL or DatabaseType.PostgreSQL or DatabaseType.DWSQL:
+ sqlEntities.TryAdd(entityName, entity);
+ break;
+ default:
+ throw new NotImplementedException($"This database type {ds.DatabaseType} is not yet implemented.");
+ }
+ }
+
+ RuntimeEntities sql = new(new ReadOnlyDictionary(sqlEntities));
+
+ DocumentNode cosmosResult = GenerateCosmosGraphQLObjects(cosmosDataSourceNames, inputObjects);
+ DocumentNode sqlResult = GenerateSqlGraphQLObjects(sql, inputObjects);
+ // Create Root node with definitions from both cosmos and sql.
+ DocumentNode root = cosmosResult.WithDefinitions(cosmosResult.Definitions.Concat(sqlResult.Definitions).ToImmutableList());
+
+ // Merge the inputobjectType definitions from cosmos and sql onto the root.
+ return (root.WithDefinitions(root.Definitions.Concat(inputObjects.Values).ToImmutableList()), inputObjects);
+ }
+ }
+}
diff --git a/src/Service.GraphQLBuilder/Sql/SchemaConverter.cs b/src/Service.GraphQLBuilder/Sql/SchemaConverter.cs
index 25985a996d..10de616da8 100644
--- a/src/Service.GraphQLBuilder/Sql/SchemaConverter.cs
+++ b/src/Service.GraphQLBuilder/Sql/SchemaConverter.cs
@@ -1,702 +1,714 @@
-// Copyright (c) Microsoft Corporation.
-// Licensed under the MIT License.
-
-using System.Collections.Immutable;
-using System.Net;
-using Azure.DataApiBuilder.Config.DatabasePrimitives;
-using Azure.DataApiBuilder.Config.ObjectModel;
-using Azure.DataApiBuilder.Service.Exceptions;
-using Azure.DataApiBuilder.Service.GraphQLBuilder.CustomScalars;
-using Azure.DataApiBuilder.Service.GraphQLBuilder.Directives;
-using Azure.DataApiBuilder.Service.GraphQLBuilder.GraphQLTypes;
-using Azure.DataApiBuilder.Service.GraphQLBuilder.Queries;
-using HotChocolate.Language;
-using HotChocolate.Types;
-using static Azure.DataApiBuilder.Service.GraphQLBuilder.GraphQLNaming;
-using static Azure.DataApiBuilder.Service.GraphQLBuilder.GraphQLStoredProcedureBuilder;
-using static Azure.DataApiBuilder.Service.GraphQLBuilder.GraphQLTypes.SupportedHotChocolateTypes;
-
-namespace Azure.DataApiBuilder.Service.GraphQLBuilder.Sql
-{
- public static class SchemaConverter
- {
- private static readonly string _aggregationTypeSuffix = "Aggregations";
- private static readonly string _groupByTypeSuffix = "GroupBy";
- public enum AggregationType
- {
- max,
- min,
- avg,
- sum,
- count
- }
-
- ///
- /// Generate a GraphQL object type from a SQL table/view/stored-procedure definition, combined with the runtime config entity information
- ///
- /// 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.
- /// Roles to add to authorize directive at the field level.
- /// A GraphQL object type to be provided to a Hot Chocolate GraphQL document.
- public static ObjectTypeDefinitionNode GenerateObjectTypeDefinitionForDatabaseObject(
- string entityName,
- DatabaseObject databaseObject,
- 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);
- }
-
- StringValueNode? descriptionNode = null;
- if (!string.IsNullOrWhiteSpace(configEntity.Description))
- {
- descriptionNode = new StringValueNode(configEntity.Description);
- }
-
- // Set the description node if available
- if (descriptionNode != null)
- {
- objectDefinitionNode = objectDefinitionNode.WithDescription(descriptionNode);
- }
-
- 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();
- 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 (storedProcedureDefinition.Columns.Count == 0)
- {
- FieldDefinitionNode field = GetDefaultResultFieldForStoredProcedure();
-
- fields.TryAdd("result", field);
- }
-
- 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);
- }
- }
-
- StringValueNode? descriptionNode = null;
- if (!string.IsNullOrWhiteSpace(configEntity.Description))
- {
- descriptionNode = new StringValueNode(configEntity.Description);
- }
-
- // 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: descriptionNode,
- 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 (column.IsReadOnly)
- {
- directives.Add(new DirectiveNode(AutoGeneratedDirectiveType.DirectiveName));
- }
-
- if (column.DefaultValue is not null)
- {
- IValueNode arg = CreateValueNodeFromDbObjectMetadata(column.DefaultValue);
-
- directives.Add(new DirectiveNode(DefaultValueDirectiveType.DirectiveName, new ArgumentNode("value", arg)));
- }
-
- // 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 at least 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.
- // This check is bypassed for linking entities for the same reason explained above.
- if (configEntity.IsLinkingEntity || roles is not null && roles.Any())
- {
- FieldDefinitionNode field = GenerateFieldForColumn(configEntity, columnName, column, directives, roles);
- fieldDefinitionNodes.Add(columnName, field);
- }
- }
- }
-
- // 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)
- {
- // 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)
- {
- foreach ((string relationshipName, EntityRelationship relationship) in configEntity.Relationships)
- {
- FieldDefinitionNode relationshipField = GenerateFieldForRelationship(
- entityName,
- databaseObject,
- entities,
- relationshipName,
- relationship);
- fieldDefinitionNodes.Add(relationshipField.Name.Value, relationshipField);
- }
- }
- }
-
- StringValueNode? descriptionNode = null;
- if (!string.IsNullOrWhiteSpace(configEntity.Description))
- {
- descriptionNode = new StringValueNode(configEntity.Description);
- }
-
- // 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: descriptionNode,
- directives: GenerateObjectTypeDirectivesForEntity(entityName, configEntity, rolesAllowedForEntity),
- new List(),
- fieldDefinitionNodes.Values.ToImmutableList());
- }
-
- public static bool IsNumericField(ITypeNode type)
- {
- string typeName = type.NamedType().Name.Value;
- return SupportedAggregateTypes.NumericAggregateTypes.Contains(typeName);
- }
-
- ///
- /// Generates aggregation type for a given entity name.
- /// Example:
- /// type BookAggregations {
- /// max(field: BookNumericAggregateFields, having: HavingInput, distinct: Boolean) : Float
- /// min(field: BookNumericAggregateFields, having: HavingInput, distinct: Boolean): Float
- /// avg(field: BookNumericAggregateFields, having: HavingInput, distinct: Boolean): Float
- /// sum(field: BookNumericAggregateFields, having: HavingInput, distinct: Boolean): Float
- /// count(field: BookNumericAggregateFields, having: HavingInput, distinct: Boolean): Int
- /// }
- ///
- ///
- ///
- ///
- public static ObjectTypeDefinitionNode GenerateAggregationTypeForEntity(string entityName, ObjectTypeDefinitionNode entityNode)
- {
- string aggregationTypeName = GenerateObjectAggregationNodeName(entityName);
-
- List numericFields = entityNode.Fields
- .Where(f => IsNumericField(f.Type))
- .Select(f => f.Type.NamedType().Name.Value)
- .ToList();
-
- List aggregationFields = new();
-
- // Add numeric aggregation fields
- if (numericFields.Any())
- {
- string filterInputType = numericFields.Count == 1 ? $"{numericFields[0]}FilterInput" : GetCommonFilterInputType(numericFields);
- aggregationFields.AddRange(new[]
- {
- CreateNumericAggregationField(AggregationType.max.ToString(), FLOAT_TYPE, "Maximum value for numeric fields", entityNode, filterInputType),
- CreateNumericAggregationField(AggregationType.min.ToString(), FLOAT_TYPE, "Minimum value for numeric fields", entityNode, filterInputType),
- CreateNumericAggregationField(AggregationType.avg.ToString(), FLOAT_TYPE, "Average value", entityNode, filterInputType),
- CreateNumericAggregationField(AggregationType.sum.ToString(), FLOAT_TYPE, "Sum of values", entityNode, filterInputType),
- CreateNumericAggregationField(AggregationType.count.ToString(), INT_TYPE, "Count of numeric values", entityNode, filterInputType)
- });
- }
-
- return new ObjectTypeDefinitionNode(
- location: null,
- name: new NameNode(aggregationTypeName),
- description: new StringValueNode($"Aggregation type for {entityName}"),
- directives: new List(),
- interfaces: new List(),
- fields: aggregationFields);
- }
-
- ///
- /// Creates a numeric aggregation field for a graphql entity.
- /// for example in the aggregations node for books it would create min/max/avg operations.
- ///
- /// The name of the aggregation operation (e.g., "sum", "avg").
- /// The return type of the aggregation operation (e.g., "Float", "Int").
- /// A description of the aggregation operation.
- /// The GraphQL entity node that contains the numeric fields to be aggregated.
- /// The input type used for filtering criteria in the aggregation operation.
- /// A representing the numeric aggregation field in the GraphQL schema.
- private static FieldDefinitionNode CreateNumericAggregationField(string operationName, string returnType, string description, ObjectTypeDefinitionNode entityNode, string filterInputType)
- {
- // Create an input type specific to this entity's numeric fields
- string inputTypeName = EnumTypeBuilder.GenerateNumericAggregateFieldsEnumName(entityNode.Name.Value);
-
- return new FieldDefinitionNode(
- location: null,
- name: new NameNode(operationName),
- description: new StringValueNode(description),
- arguments: new List
- {
- new(null,
- new NameNode("field"),
- new StringValueNode("Field to aggregate on"),
- new NonNullTypeNode(new NamedTypeNode(new NameNode(inputTypeName))),
- null,
- new List()),
- new(null,
- new NameNode("having"),
- new StringValueNode("Filter criteria for aggregation"),
- new NamedTypeNode(new NameNode(filterInputType)),
- null,
- new List()),
- new(null,
- new NameNode("distinct"),
- new StringValueNode("Whether to aggregate on distinct values"),
- new BooleanType().ToTypeNode(),
- new BooleanValueNode(false),
- new List())
- },
- type: new NamedTypeNode(new NameNode(returnType)),
- directives: new List());
- }
-
- ///
- /// Generates a GroupBy type for a given entity that includes fields and aggregations.
- /// Example:
- /// type BookGroupBy {
- /// fields: [BookScalarFields]
- /// aggregations: BookAggregations
- /// }
- ///
- /// Name of the entity
- /// The entity's ObjectTypeDefinitionNode
- /// ObjectTypeDefinitionNode for the GroupBy type
- public static ObjectTypeDefinitionNode GenerateGroupByTypeForEntity(string entityName, ObjectTypeDefinitionNode entityNode)
- {
- string groupByTypeName = GenerateGroupByTypeName(entityName);
-
- List groupByFields = new()
- {
- new FieldDefinitionNode(
- location: null,
- name: new NameNode("fields"),
- description: new StringValueNode($"Grouped fields from {entityName}"),
- arguments: new List(),
- type: new NamedTypeNode(new NameNode(entityName)),
- directives: new List()
- )
- };
-
- return new ObjectTypeDefinitionNode(
- location: null,
- name: new NameNode(groupByTypeName),
- description: new StringValueNode($"GroupBy type for {entityName}"),
- directives: new List(),
- interfaces: new List(),
- fields: groupByFields);
- }
-
- ///
- /// Determines the most appropriate common filter input type for a collection of numeric types
- ///
- private static string GetCommonFilterInputType(List numericTypes)
- {
- Dictionary typeHierarchy = new()
- {
- { DECIMAL_TYPE, 1 },
- { FLOAT_TYPE, 2 },
- { SINGLE_TYPE, 3 },
- { LONG_TYPE, 4 },
- { INT_TYPE, 5 },
- { SHORT_TYPE, 6 },
- { BYTE_TYPE, 7 }
- };
-
- // Find the highest precision type among the numeric types
- string highestPrecisionType = numericTypes
- .OrderBy(t => typeHierarchy.GetValueOrDefault(t, 0))
- .First();
-
- return $"{highestPrecisionType}FilterInput";
- }
- ///
- /// 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!);
- }
-
- // Determine the exposed column name considering mappings and aliases
- string exposedColumnName = columnName;
- if (configEntity.Mappings is not null && configEntity.Mappings.TryGetValue(key: columnName, out string? columnAlias))
- {
- exposedColumnName = columnAlias;
- }
-
- // Apply alias if present (alias overrides mapping)
- FieldMetadata? fieldMetadata = null;
- if (configEntity.Fields is not null)
- {
- fieldMetadata = configEntity.Fields.FirstOrDefault(f => f.Name == columnName);
- if (fieldMetadata != null && !string.IsNullOrEmpty(fieldMetadata.Alias))
- {
- exposedColumnName = fieldMetadata.Alias;
- }
- }
-
- NamedTypeNode fieldType = new(GetGraphQLTypeFromSystemType(column.SystemType));
- FieldDefinitionNode field = new(
- location: null,
- new(exposedColumnName),
- description: fieldMetadata?.Description is null ? null : new StringValueNode(fieldMetadata.Description),
- 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 DirectiveNode(
- ModelDirective.Names.MODEL,
- new ArgumentNode(ModelDirective.Names.NAME_ARGUMENT, entityName)));
-
- if (GraphQLUtils.CreateAuthorizationDirectiveIfNecessary(
- rolesAllowedForEntity,
- out DirectiveNode? authorizeDirective))
- {
- objectTypeDirectives.Add(authorizeDirective!);
- }
- }
-
- return objectTypeDirectives;
- }
-
- ///
- /// Get the GraphQL type equivalent from passed in system Type
- ///
- /// System type.
- /// Raised when the provided type does not map to a supported
- /// GraphQL type."
- public static string GetGraphQLTypeFromSystemType(Type type)
- {
- return type.Name switch
- {
- "String" => STRING_TYPE,
- "Guid" => UUID_TYPE,
- "Byte" => BYTE_TYPE,
- "Int16" => SHORT_TYPE,
- "Int32" => INT_TYPE,
- "Int64" => LONG_TYPE,
- "Single" => SINGLE_TYPE,
- "Double" => FLOAT_TYPE,
- "Decimal" => DECIMAL_TYPE,
- "Boolean" => BOOLEAN_TYPE,
- "DateTime" => DATETIME_TYPE,
- "DateTimeOffset" => DATETIME_TYPE,
- "Byte[]" => BYTEARRAY_TYPE,
- "TimeOnly" => LOCALTIME_TYPE,
- "TimeSpan" => LOCALTIME_TYPE,
- _ => throw new DataApiBuilderException(
- message: $"Column type {type} not handled by case. Please add a case resolving {type} to the appropriate GraphQL type",
- statusCode: HttpStatusCode.InternalServerError,
- subStatusCode: DataApiBuilderException.SubStatusCodes.GraphQLMapping)
- };
- }
-
- ///
- /// Translates system type objects to HotChocolate ObjectValueNode's of the associated value type used for GraphQL schema creation.
- /// The HotChocolate IntValueNode has contructors for integral numeric types (byte, short, long) to
- /// maintain the precision of the input object's value.
- ///
- /// Object to be converted to GraphQL ObjectValueNode
- /// The resulting IValueNode object converted from the input system type object.
- ///
- /// Raised when the input argument's value type does not map to a supported GraphQL type.
- public static IValueNode CreateValueNodeFromDbObjectMetadata(object metadataValue)
- {
- IValueNode arg = metadataValue switch
- {
- byte value => new ObjectValueNode(new ObjectFieldNode(BYTE_TYPE, new IntValueNode(value))),
- short value => new ObjectValueNode(new ObjectFieldNode(SHORT_TYPE, new IntValueNode(value))),
- int value => new ObjectValueNode(new ObjectFieldNode(INT_TYPE, value)),
- long value => new ObjectValueNode(new ObjectFieldNode(LONG_TYPE, new IntValueNode(value))),
- Guid value => new ObjectValueNode(new ObjectFieldNode(UUID_TYPE, new UuidType().ValueToLiteral(value))),
- string value => new ObjectValueNode(new ObjectFieldNode(STRING_TYPE, value)),
- bool value => new ObjectValueNode(new ObjectFieldNode(BOOLEAN_TYPE, value)),
- float value => new ObjectValueNode(new ObjectFieldNode(SINGLE_TYPE, new SingleType().ValueToLiteral(value))),
- double value => new ObjectValueNode(new ObjectFieldNode(FLOAT_TYPE, value)),
- decimal value => new ObjectValueNode(new ObjectFieldNode(DECIMAL_TYPE, new FloatValueNode(value))),
- DateTimeOffset value => new ObjectValueNode(new ObjectFieldNode(DATETIME_TYPE, new DateTimeType().ValueToLiteral(value))),
- DateTime value => new ObjectValueNode(new ObjectFieldNode(DATETIME_TYPE, new DateTimeType().ValueToLiteral(
- value.Kind == DateTimeKind.Unspecified ? new DateTimeOffset(value, TimeSpan.Zero) : new DateTimeOffset(value)))),
- byte[] value => new ObjectValueNode(new ObjectFieldNode(BYTEARRAY_TYPE, new Base64StringType().ValueToLiteral(value))),
- TimeOnly value => new ObjectValueNode(new ObjectFieldNode(LOCALTIME_TYPE, new HotChocolate.Types.NodaTime.LocalTimeType().ValueToLiteral(value))),
- _ => throw new DataApiBuilderException(
- message: $"The type {metadataValue.GetType()} is not supported as a GraphQL default value",
- statusCode: HttpStatusCode.InternalServerError,
- subStatusCode: DataApiBuilderException.SubStatusCodes.GraphQLMapping)
- };
-
- return arg;
- }
-
- ///
- /// Given the source entity name, its underlying database object and the targetEntityName,
- /// finds if the relationship field corresponding to the target should be nullable
- /// based on whether the source is the referencing or referenced object or both.
- ///
- /// Raised no relationship exists between the source and target
- /// entities.
- private static bool FindNullabilityOfRelationship(
- string entityName,
- DatabaseObject databaseObject,
- string targetEntityName)
- {
- bool isNullableRelationship = false;
- SourceDefinition sourceDefinition = databaseObject.SourceDefinition;
- if (// Retrieve all the relationship information for the source entity which is backed by this table definition
- sourceDefinition.SourceEntityRelationshipMap.TryGetValue(entityName, out RelationshipMetadata? relationshipInfo) &&
- // From the relationship information, obtain the foreign key definition for the given target entity
- relationshipInfo.TargetEntityToFkDefinitionMap.TryGetValue(targetEntityName,
- out List? listOfForeignKeys))
- {
- // DAB optimistically adds entries to 'listOfForeignKeys' representing each relationship direction
- // between a pair of entities when 1:1 or many:1 relationships are defined in the runtime config.
- // Entries which don't have a matching corresponding foreign key in the database
- // will have 0 referencing/referenced columns. So, we need to filter out these
- // invalid entries. Non-zero referenced columns indicate valid matching foreign key definition in the
- // database and hence only those can be used to determine the directionality.
-
- // Find the foreign keys in which the source entity is the referencing object.
- ForeignKeyDefinition[] referencingForeignKeyInfo =
- listOfForeignKeys.Where(fk =>
- fk.ReferencingColumns.Count > 0
- && fk.ReferencedColumns.Count > 0
- && fk.Pair.ReferencingDbTable.Equals(databaseObject))
- .ToArray();
-
- // Find the foreign keys in which the source entity is the referenced object.
- ForeignKeyDefinition[] referencedForeignKeyInfo =
- listOfForeignKeys.Where(fk =>
- fk.ReferencingColumns.Count > 0
- && fk.ReferencedColumns.Count > 0
- && fk.Pair.ReferencedDbTable.Equals(databaseObject))
- .ToArray();
-
- // The source entity should at least be a referencing or referenced db object or both
- // in the foreign key relationship.
- if (referencingForeignKeyInfo.Length != 0 || referencedForeignKeyInfo.Length != 0)
- {
- // The source entity could be both the referencing and referenced entity
- // in case of missing foreign keys in the db or self referencing relationships.
- // Use the nullability of referencing columns to determine
- // the nullability of the relationship field only if
- // 1. there is exactly one relationship where source is the referencing entity.
- // DAB doesn't support multiple relationships at the moment.
- // and
- // 2. when the source is not a referenced entity in any of the relationships.
- if (referencingForeignKeyInfo.Length == 1 && referencedForeignKeyInfo.Length == 0)
- {
- ForeignKeyDefinition foreignKeyInfo = referencingForeignKeyInfo[0];
- isNullableRelationship = sourceDefinition.IsAnyColumnNullable(foreignKeyInfo.ReferencingColumns);
- }
- else
- {
- // a record of the "referenced" entity may or may not have a relationship with
- // any other record of the referencing entity in the database
- // (irrespective of nullability of the referenced columns)
- // Setting the relationship field to nullable ensures even those records
- // that are not related are considered while querying.
- isNullableRelationship = true;
- }
- }
- else
- {
- throw new DataApiBuilderException(
- message: $"No relationship exists between {entityName} and {targetEntityName}",
- statusCode: HttpStatusCode.InternalServerError,
- subStatusCode: DataApiBuilderException.SubStatusCodes.GraphQLMapping);
- }
- }
-
- return isNullableRelationship;
- }
-
- ///
- /// Returns the aggregation node name for the given entity name.
- ///
- /// input entity name.
- /// {entityName}Aggregations
- public static string GenerateObjectAggregationNodeName(string entityName)
- {
- return $"{entityName}{_aggregationTypeSuffix}";
- }
-
- public static string GenerateGroupByTypeName(string entityName)
- {
- return $"{entityName}{_groupByTypeSuffix}";
- }
- }
-}
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Collections.Immutable;
+using System.Net;
+using Azure.DataApiBuilder.Config.DatabasePrimitives;
+using Azure.DataApiBuilder.Config.ObjectModel;
+using Azure.DataApiBuilder.Service.Exceptions;
+using Azure.DataApiBuilder.Service.GraphQLBuilder.CustomScalars;
+using Azure.DataApiBuilder.Service.GraphQLBuilder.Directives;
+using Azure.DataApiBuilder.Service.GraphQLBuilder.GraphQLTypes;
+using Azure.DataApiBuilder.Service.GraphQLBuilder.Queries;
+using HotChocolate.Language;
+using HotChocolate.Types;
+using static Azure.DataApiBuilder.Service.GraphQLBuilder.GraphQLNaming;
+using static Azure.DataApiBuilder.Service.GraphQLBuilder.GraphQLStoredProcedureBuilder;
+using static Azure.DataApiBuilder.Service.GraphQLBuilder.GraphQLTypes.SupportedHotChocolateTypes;
+
+namespace Azure.DataApiBuilder.Service.GraphQLBuilder.Sql
+{
+ public static class SchemaConverter
+ {
+ private static readonly string _aggregationTypeSuffix = "Aggregations";
+ private static readonly string _groupByTypeSuffix = "GroupBy";
+ public enum AggregationType
+ {
+ max,
+ min,
+ avg,
+ sum,
+ count
+ }
+
+ ///
+ /// Generate a GraphQL object type from a SQL table/view/stored-procedure definition, combined with the runtime config entity information
+ ///
+ /// 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.
+ /// Roles to add to authorize directive at the field level.
+ /// A GraphQL object type to be provided to a Hot Chocolate GraphQL document.
+ public static ObjectTypeDefinitionNode GenerateObjectTypeDefinitionForDatabaseObject(
+ string entityName,
+ DatabaseObject databaseObject,
+ Entity configEntity,
+ RuntimeEntities entities,
+ IEnumerable rolesAllowedForEntity,
+ IDictionary> rolesAllowedForFields,
+ DatabaseType databaseType = DatabaseType.MSSQL)
+ {
+ 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,
+ databaseType: databaseType);
+ break;
+ default:
+ throw new DataApiBuilderException(
+ message: $"The source type of entity: {entityName} is not supported",
+ statusCode: HttpStatusCode.ServiceUnavailable,
+ subStatusCode: DataApiBuilderException.SubStatusCodes.NotSupported);
+ }
+
+ StringValueNode? descriptionNode = null;
+ if (!string.IsNullOrWhiteSpace(configEntity.Description))
+ {
+ descriptionNode = new StringValueNode(configEntity.Description);
+ }
+
+ // Set the description node if available
+ if (descriptionNode != null)
+ {
+ objectDefinitionNode = objectDefinitionNode.WithDescription(descriptionNode);
+ }
+
+ 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();
+ 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 (storedProcedureDefinition.Columns.Count == 0)
+ {
+ FieldDefinitionNode field = GetDefaultResultFieldForStoredProcedure();
+
+ fields.TryAdd("result", field);
+ }
+
+ 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);
+ }
+ }
+
+ StringValueNode? descriptionNode = null;
+ if (!string.IsNullOrWhiteSpace(configEntity.Description))
+ {
+ descriptionNode = new StringValueNode(configEntity.Description);
+ }
+
+ // 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: descriptionNode,
+ 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,
+ DatabaseType databaseType = DatabaseType.MSSQL)
+ {
+ 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 (column.IsReadOnly)
+ {
+ directives.Add(new DirectiveNode(AutoGeneratedDirectiveType.DirectiveName));
+ }
+
+ if (column.DefaultValue is not null)
+ {
+ IValueNode arg = CreateValueNodeFromDbObjectMetadata(column.DefaultValue);
+
+ directives.Add(new DirectiveNode(DefaultValueDirectiveType.DirectiveName, new ArgumentNode("value", arg)));
+ }
+
+ // 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 at least 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.
+ // This check is bypassed for linking entities for the same reason explained above.
+ if (configEntity.IsLinkingEntity || roles is not null && roles.Any())
+ {
+ FieldDefinitionNode field = GenerateFieldForColumn(configEntity, columnName, column, directives, roles);
+ fieldDefinitionNodes.Add(columnName, field);
+ }
+ }
+ }
+
+ // 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)
+ {
+ // 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)
+ {
+ foreach ((string relationshipName, EntityRelationship relationship) in configEntity.Relationships)
+ {
+ FieldDefinitionNode relationshipField = GenerateFieldForRelationship(
+ entityName,
+ databaseObject,
+ entities,
+ relationshipName,
+ relationship,
+ databaseType);
+ fieldDefinitionNodes.Add(relationshipField.Name.Value, relationshipField);
+ }
+ }
+ }
+
+ StringValueNode? descriptionNode = null;
+ if (!string.IsNullOrWhiteSpace(configEntity.Description))
+ {
+ descriptionNode = new StringValueNode(configEntity.Description);
+ }
+
+ // 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: descriptionNode,
+ directives: GenerateObjectTypeDirectivesForEntity(entityName, configEntity, rolesAllowedForEntity),
+ new List(),
+ fieldDefinitionNodes.Values.ToImmutableList());
+ }
+
+ public static bool IsNumericField(ITypeNode type)
+ {
+ string typeName = type.NamedType().Name.Value;
+ return SupportedAggregateTypes.NumericAggregateTypes.Contains(typeName);
+ }
+
+ ///
+ /// Generates aggregation type for a given entity name.
+ /// Example:
+ /// type BookAggregations {
+ /// max(field: BookNumericAggregateFields, having: HavingInput, distinct: Boolean) : Float
+ /// min(field: BookNumericAggregateFields, having: HavingInput, distinct: Boolean): Float
+ /// avg(field: BookNumericAggregateFields, having: HavingInput, distinct: Boolean): Float
+ /// sum(field: BookNumericAggregateFields, having: HavingInput, distinct: Boolean): Float
+ /// count(field: BookNumericAggregateFields, having: HavingInput, distinct: Boolean): Int
+ /// }
+ ///
+ ///
+ ///
+ ///
+ public static ObjectTypeDefinitionNode GenerateAggregationTypeForEntity(string entityName, ObjectTypeDefinitionNode entityNode)
+ {
+ string aggregationTypeName = GenerateObjectAggregationNodeName(entityName);
+
+ List numericFields = entityNode.Fields
+ .Where(f => IsNumericField(f.Type))
+ .Select(f => f.Type.NamedType().Name.Value)
+ .ToList();
+
+ List aggregationFields = new();
+
+ // Add numeric aggregation fields
+ if (numericFields.Any())
+ {
+ string filterInputType = numericFields.Count == 1 ? $"{numericFields[0]}FilterInput" : GetCommonFilterInputType(numericFields);
+ aggregationFields.AddRange(new[]
+ {
+ CreateNumericAggregationField(AggregationType.max.ToString(), FLOAT_TYPE, "Maximum value for numeric fields", entityNode, filterInputType),
+ CreateNumericAggregationField(AggregationType.min.ToString(), FLOAT_TYPE, "Minimum value for numeric fields", entityNode, filterInputType),
+ CreateNumericAggregationField(AggregationType.avg.ToString(), FLOAT_TYPE, "Average value", entityNode, filterInputType),
+ CreateNumericAggregationField(AggregationType.sum.ToString(), FLOAT_TYPE, "Sum of values", entityNode, filterInputType),
+ CreateNumericAggregationField(AggregationType.count.ToString(), INT_TYPE, "Count of numeric values", entityNode, filterInputType)
+ });
+ }
+
+ return new ObjectTypeDefinitionNode(
+ location: null,
+ name: new NameNode(aggregationTypeName),
+ description: new StringValueNode($"Aggregation type for {entityName}"),
+ directives: new List(),
+ interfaces: new List(),
+ fields: aggregationFields);
+ }
+
+ ///
+ /// Creates a numeric aggregation field for a graphql entity.
+ /// for example in the aggregations node for books it would create min/max/avg operations.
+ ///
+ /// The name of the aggregation operation (e.g., "sum", "avg").
+ /// The return type of the aggregation operation (e.g., "Float", "Int").
+ /// A description of the aggregation operation.
+ /// The GraphQL entity node that contains the numeric fields to be aggregated.
+ /// The input type used for filtering criteria in the aggregation operation.
+ /// A representing the numeric aggregation field in the GraphQL schema.
+ private static FieldDefinitionNode CreateNumericAggregationField(string operationName, string returnType, string description, ObjectTypeDefinitionNode entityNode, string filterInputType)
+ {
+ // Create an input type specific to this entity's numeric fields
+ string inputTypeName = EnumTypeBuilder.GenerateNumericAggregateFieldsEnumName(entityNode.Name.Value);
+
+ return new FieldDefinitionNode(
+ location: null,
+ name: new NameNode(operationName),
+ description: new StringValueNode(description),
+ arguments: new List
+ {
+ new(null,
+ new NameNode("field"),
+ new StringValueNode("Field to aggregate on"),
+ new NonNullTypeNode(new NamedTypeNode(new NameNode(inputTypeName))),
+ null,
+ new List()),
+ new(null,
+ new NameNode("having"),
+ new StringValueNode("Filter criteria for aggregation"),
+ new NamedTypeNode(new NameNode(filterInputType)),
+ null,
+ new List()),
+ new(null,
+ new NameNode("distinct"),
+ new StringValueNode("Whether to aggregate on distinct values"),
+ new BooleanType().ToTypeNode(),
+ new BooleanValueNode(false),
+ new List())
+ },
+ type: new NamedTypeNode(new NameNode(returnType)),
+ directives: new List());
+ }
+
+ ///
+ /// Generates a GroupBy type for a given entity that includes fields and aggregations.
+ /// Example:
+ /// type BookGroupBy {
+ /// fields: [BookScalarFields]
+ /// aggregations: BookAggregations
+ /// }
+ ///
+ /// Name of the entity
+ /// The entity's ObjectTypeDefinitionNode
+ /// ObjectTypeDefinitionNode for the GroupBy type
+ public static ObjectTypeDefinitionNode GenerateGroupByTypeForEntity(string entityName, ObjectTypeDefinitionNode entityNode)
+ {
+ string groupByTypeName = GenerateGroupByTypeName(entityName);
+
+ List groupByFields = new()
+ {
+ new FieldDefinitionNode(
+ location: null,
+ name: new NameNode("fields"),
+ description: new StringValueNode($"Grouped fields from {entityName}"),
+ arguments: new List(),
+ type: new NamedTypeNode(new NameNode(entityName)),
+ directives: new List()
+ )
+ };
+
+ return new ObjectTypeDefinitionNode(
+ location: null,
+ name: new NameNode(groupByTypeName),
+ description: new StringValueNode($"GroupBy type for {entityName}"),
+ directives: new List(),
+ interfaces: new List(),
+ fields: groupByFields);
+ }
+
+ ///
+ /// Determines the most appropriate common filter input type for a collection of numeric types
+ ///
+ private static string GetCommonFilterInputType(List numericTypes)
+ {
+ Dictionary typeHierarchy = new()
+ {
+ { DECIMAL_TYPE, 1 },
+ { FLOAT_TYPE, 2 },
+ { SINGLE_TYPE, 3 },
+ { LONG_TYPE, 4 },
+ { INT_TYPE, 5 },
+ { SHORT_TYPE, 6 },
+ { BYTE_TYPE, 7 }
+ };
+
+ // Find the highest precision type among the numeric types
+ string highestPrecisionType = numericTypes
+ .OrderBy(t => typeHierarchy.GetValueOrDefault(t, 0))
+ .First();
+
+ return $"{highestPrecisionType}FilterInput";
+ }
+ ///
+ /// 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!);
+ }
+
+ // Determine the exposed column name considering mappings and aliases
+ string exposedColumnName = columnName;
+ if (configEntity.Mappings is not null && configEntity.Mappings.TryGetValue(key: columnName, out string? columnAlias))
+ {
+ exposedColumnName = columnAlias;
+ }
+
+ // Apply alias if present (alias overrides mapping)
+ FieldMetadata? fieldMetadata = null;
+ if (configEntity.Fields is not null)
+ {
+ fieldMetadata = configEntity.Fields.FirstOrDefault(f => f.Name == columnName);
+ if (fieldMetadata != null && !string.IsNullOrEmpty(fieldMetadata.Alias))
+ {
+ exposedColumnName = fieldMetadata.Alias;
+ }
+ }
+
+ NamedTypeNode fieldType = new(GetGraphQLTypeFromSystemType(column.SystemType));
+ FieldDefinitionNode field = new(
+ location: null,
+ new(exposedColumnName),
+ description: fieldMetadata?.Description is null ? null : new StringValueNode(fieldMetadata.Description),
+ 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,
+ DatabaseType databaseType = DatabaseType.MSSQL)
+ {
+ // 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, databaseType);
+
+ 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 DirectiveNode(
+ ModelDirective.Names.MODEL,
+ new ArgumentNode(ModelDirective.Names.NAME_ARGUMENT, entityName)));
+
+ if (GraphQLUtils.CreateAuthorizationDirectiveIfNecessary(
+ rolesAllowedForEntity,
+ out DirectiveNode? authorizeDirective))
+ {
+ objectTypeDirectives.Add(authorizeDirective!);
+ }
+ }
+
+ return objectTypeDirectives;
+ }
+
+ ///
+ /// Get the GraphQL type equivalent from passed in system Type
+ ///
+ /// System type.
+ /// Raised when the provided type does not map to a supported
+ /// GraphQL type."
+ public static string GetGraphQLTypeFromSystemType(Type type)
+ {
+ return type.Name switch
+ {
+ "String" => STRING_TYPE,
+ "Guid" => UUID_TYPE,
+ "Byte" => BYTE_TYPE,
+ "Int16" => SHORT_TYPE,
+ "Int32" => INT_TYPE,
+ "Int64" => LONG_TYPE,
+ "Single" => SINGLE_TYPE,
+ "Double" => FLOAT_TYPE,
+ "Decimal" => DECIMAL_TYPE,
+ "Boolean" => BOOLEAN_TYPE,
+ "DateTime" => DATETIME_TYPE,
+ "DateTimeOffset" => DATETIME_TYPE,
+ "Byte[]" => BYTEARRAY_TYPE,
+ "TimeOnly" => LOCALTIME_TYPE,
+ "TimeSpan" => LOCALTIME_TYPE,
+ _ => throw new DataApiBuilderException(
+ message: $"Column type {type} not handled by case. Please add a case resolving {type} to the appropriate GraphQL type",
+ statusCode: HttpStatusCode.InternalServerError,
+ subStatusCode: DataApiBuilderException.SubStatusCodes.GraphQLMapping)
+ };
+ }
+
+ ///
+ /// Translates system type objects to HotChocolate ObjectValueNode's of the associated value type used for GraphQL schema creation.
+ /// The HotChocolate IntValueNode has contructors for integral numeric types (byte, short, long) to
+ /// maintain the precision of the input object's value.
+ ///
+ /// Object to be converted to GraphQL ObjectValueNode
+ /// The resulting IValueNode object converted from the input system type object.
+ ///
+ /// Raised when the input argument's value type does not map to a supported GraphQL type.
+ public static IValueNode CreateValueNodeFromDbObjectMetadata(object metadataValue)
+ {
+ IValueNode arg = metadataValue switch
+ {
+ byte value => new ObjectValueNode(new ObjectFieldNode(BYTE_TYPE, new IntValueNode(value))),
+ short value => new ObjectValueNode(new ObjectFieldNode(SHORT_TYPE, new IntValueNode(value))),
+ int value => new ObjectValueNode(new ObjectFieldNode(INT_TYPE, value)),
+ long value => new ObjectValueNode(new ObjectFieldNode(LONG_TYPE, new IntValueNode(value))),
+ Guid value => new ObjectValueNode(new ObjectFieldNode(UUID_TYPE, new UuidType().ValueToLiteral(value))),
+ string value => new ObjectValueNode(new ObjectFieldNode(STRING_TYPE, value)),
+ bool value => new ObjectValueNode(new ObjectFieldNode(BOOLEAN_TYPE, value)),
+ float value => new ObjectValueNode(new ObjectFieldNode(SINGLE_TYPE, new SingleType().ValueToLiteral(value))),
+ double value => new ObjectValueNode(new ObjectFieldNode(FLOAT_TYPE, value)),
+ decimal value => new ObjectValueNode(new ObjectFieldNode(DECIMAL_TYPE, new FloatValueNode(value))),
+ DateTimeOffset value => new ObjectValueNode(new ObjectFieldNode(DATETIME_TYPE, new DateTimeType().ValueToLiteral(value))),
+ DateTime value => new ObjectValueNode(new ObjectFieldNode(DATETIME_TYPE, new DateTimeType().ValueToLiteral(
+ value.Kind == DateTimeKind.Unspecified ? new DateTimeOffset(value, TimeSpan.Zero) : new DateTimeOffset(value)))),
+ byte[] value => new ObjectValueNode(new ObjectFieldNode(BYTEARRAY_TYPE, new Base64StringType().ValueToLiteral(value))),
+ TimeOnly value => new ObjectValueNode(new ObjectFieldNode(LOCALTIME_TYPE, new HotChocolate.Types.NodaTime.LocalTimeType().ValueToLiteral(value))),
+ _ => throw new DataApiBuilderException(
+ message: $"The type {metadataValue.GetType()} is not supported as a GraphQL default value",
+ statusCode: HttpStatusCode.InternalServerError,
+ subStatusCode: DataApiBuilderException.SubStatusCodes.GraphQLMapping)
+ };
+
+ return arg;
+ }
+
+ ///
+ /// Given the source entity name, its underlying database object and the targetEntityName,
+ /// finds if the relationship field corresponding to the target should be nullable
+ /// based on whether the source is the referencing or referenced object or both.
+ ///
+ /// Raised no relationship exists between the source and target
+ /// entities.
+ private static bool FindNullabilityOfRelationship(
+ string entityName,
+ DatabaseObject databaseObject,
+ string targetEntityName,
+ DatabaseType databaseType = DatabaseType.MSSQL)
+ {
+ // DWSQL does not enforce foreign key constraints, so relationship fields are always nullable.
+ if (databaseType == DatabaseType.DWSQL)
+ {
+ return true;
+ }
+
+ bool isNullableRelationship = false;
+ SourceDefinition sourceDefinition = databaseObject.SourceDefinition;
+ if (// Retrieve all the relationship information for the source entity which is backed by this table definition
+ sourceDefinition.SourceEntityRelationshipMap.TryGetValue(entityName, out RelationshipMetadata? relationshipInfo) &&
+ // From the relationship information, obtain the foreign key definition for the given target entity
+ relationshipInfo.TargetEntityToFkDefinitionMap.TryGetValue(targetEntityName,
+ out List? listOfForeignKeys))
+ {
+ // DAB optimistically adds entries to 'listOfForeignKeys' representing each relationship direction
+ // between a pair of entities when 1:1 or many:1 relationships are defined in the runtime config.
+ // Entries which don't have a matching corresponding foreign key in the database
+ // will have 0 referencing/referenced columns. So, we need to filter out these
+ // invalid entries. Non-zero referenced columns indicate valid matching foreign key definition in the
+ // database and hence only those can be used to determine the directionality.
+
+ // Find the foreign keys in which the source entity is the referencing object.
+ ForeignKeyDefinition[] referencingForeignKeyInfo =
+ listOfForeignKeys.Where(fk =>
+ fk.ReferencingColumns.Count > 0
+ && fk.ReferencedColumns.Count > 0
+ && fk.Pair.ReferencingDbTable.Equals(databaseObject))
+ .ToArray();
+
+ // Find the foreign keys in which the source entity is the referenced object.
+ ForeignKeyDefinition[] referencedForeignKeyInfo =
+ listOfForeignKeys.Where(fk =>
+ fk.ReferencingColumns.Count > 0
+ && fk.ReferencedColumns.Count > 0
+ && fk.Pair.ReferencedDbTable.Equals(databaseObject))
+ .ToArray();
+
+ // The source entity should at least be a referencing or referenced db object or both
+ // in the foreign key relationship.
+ if (referencingForeignKeyInfo.Length != 0 || referencedForeignKeyInfo.Length != 0)
+ {
+ // The source entity could be both the referencing and referenced entity
+ // in case of missing foreign keys in the db or self referencing relationships.
+ // Use the nullability of referencing columns to determine
+ // the nullability of the relationship field only if
+ // 1. there is exactly one relationship where source is the referencing entity.
+ // DAB doesn't support multiple relationships at the moment.
+ // and
+ // 2. when the source is not a referenced entity in any of the relationships.
+ if (referencingForeignKeyInfo.Length == 1 && referencedForeignKeyInfo.Length == 0)
+ {
+ ForeignKeyDefinition foreignKeyInfo = referencingForeignKeyInfo[0];
+ isNullableRelationship = sourceDefinition.IsAnyColumnNullable(foreignKeyInfo.ReferencingColumns);
+ }
+ else
+ {
+ // a record of the "referenced" entity may or may not have a relationship with
+ // any other record of the referencing entity in the database
+ // (irrespective of nullability of the referenced columns)
+ // Setting the relationship field to nullable ensures even those records
+ // that are not related are considered while querying.
+ isNullableRelationship = true;
+ }
+ }
+ else
+ {
+ throw new DataApiBuilderException(
+ message: $"No relationship exists between {entityName} and {targetEntityName}",
+ statusCode: HttpStatusCode.InternalServerError,
+ subStatusCode: DataApiBuilderException.SubStatusCodes.GraphQLMapping);
+ }
+ }
+
+ return isNullableRelationship;
+ }
+
+ ///
+ /// Returns the aggregation node name for the given entity name.
+ ///
+ /// input entity name.
+ /// {entityName}Aggregations
+ public static string GenerateObjectAggregationNodeName(string entityName)
+ {
+ return $"{entityName}{_aggregationTypeSuffix}";
+ }
+
+ public static string GenerateGroupByTypeName(string entityName)
+ {
+ return $"{entityName}{_groupByTypeSuffix}";
+ }
+ }
+}
diff --git a/src/Service.Tests/GraphQLBuilder/Sql/SchemaConverterTests.cs b/src/Service.Tests/GraphQLBuilder/Sql/SchemaConverterTests.cs
index 84806adc78..ed9a82b44a 100644
--- a/src/Service.Tests/GraphQLBuilder/Sql/SchemaConverterTests.cs
+++ b/src/Service.Tests/GraphQLBuilder/Sql/SchemaConverterTests.cs
@@ -1,954 +1,970 @@
-// Copyright (c) Microsoft Corporation.
-// Licensed under the MIT License.
-
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using Azure.DataApiBuilder.Config.DatabasePrimitives;
-using Azure.DataApiBuilder.Config.ObjectModel;
-using Azure.DataApiBuilder.Service.GraphQLBuilder;
-using Azure.DataApiBuilder.Service.GraphQLBuilder.Directives;
-using Azure.DataApiBuilder.Service.GraphQLBuilder.Queries;
-using Azure.DataApiBuilder.Service.GraphQLBuilder.Sql;
-using HotChocolate.Language;
-using Microsoft.VisualStudio.TestTools.UnitTesting;
-using static Azure.DataApiBuilder.Service.GraphQLBuilder.GraphQLTypes.SupportedHotChocolateTypes;
-
-namespace Azure.DataApiBuilder.Service.Tests.GraphQLBuilder.Sql
-{
- [TestClass]
- [TestCategory("GraphQL Schema Builder")]
- public class SchemaConverterTests
- {
- const string SCHEMA_NAME = "dbo";
- const string TABLE_NAME = "tableName";
- const string COLUMN_NAME = "columnName";
- const string REF_COLNAME = "ref_col_in_source";
- const string SOURCE_ENTITY = "sourceEntity";
- const string FIELD_NAME_FOR_TARGET = "target";
-
- const string TARGET_ENTITY = "TargetEntity";
- const string REFERENCED_TABLE = "fkTable";
- const string REFD_COLNAME = "fk_col";
-
- [DataTestMethod]
- [DataRow("test", "test")]
- [DataRow("Test", "Test")]
- [DataRow("T_est", "T_est")]
- [DataRow("Test1", "Test1")]
- public void EntityNameBecomesObjectName(string entityName, string expected)
- {
- DatabaseObject dbObject = new DatabaseTable { TableDefinition = new SourceDefinition() };
-
- ObjectTypeDefinitionNode od = SchemaConverter.GenerateObjectTypeDefinitionForDatabaseObject(
- entityName,
- dbObject,
- GenerateEmptyEntity(entityName),
- new(new Dictionary()),
- rolesAllowedForEntity: GetRolesAllowedForEntity(),
- rolesAllowedForFields: GetFieldToRolesMap()
- );
-
- Assert.AreEqual(expected, od.Name.Value);
- }
-
- ///
- /// Validates that schema generation does not modify names.
- ///
- ///
- ///
- [DataTestMethod]
- [DataRow("test", "test")]
- [DataRow("Test", "Test")]
- public void ColumnNameBecomesFieldName(string columnName, string expected)
- {
- SourceDefinition table = new();
- table.Columns.Add(columnName, new ColumnDefinition
- {
- SystemType = typeof(string)
- });
-
- DatabaseObject dbObject = new DatabaseTable() { TableDefinition = table };
-
- ObjectTypeDefinitionNode od = SchemaConverter.GenerateObjectTypeDefinitionForDatabaseObject(
- "table",
- dbObject,
- GenerateEmptyEntity("table"),
- new(new Dictionary()),
- rolesAllowedForEntity: GetRolesAllowedForEntity(),
- rolesAllowedForFields: GetFieldToRolesMap(columnName: table.Columns.First().Key)
- );
-
- Assert.AreEqual(expected, od.Fields[0].Name.Value);
- }
-
- ///
- /// Tests that an Entity object's mapping configuration is utilized in the schema generator
- /// by checking that mapped column values are used for field names instead of backing column names.
- ///
- /// Whether to add mapping entries to the mappings collection.
- /// Name of database column.
- /// Configured alternative (mapped) name of column to be used in REST/GraphQL endpoints.
- /// Whether GraphQL object field name should equal the mapped column name provided.
- [DataTestMethod]
- [DataRow(true, "__typename", "typename", true, DisplayName = "Mapped column name fixes GraphQL introspection naming violation. ")]
- [DataRow(false, "typename", "mappedtypename", false, DisplayName = "Mapped column name ")]
- public void FieldNameMatchesMappedValue(bool setMappings, string backingColumnName, string mappedName, bool expectMappedName)
- {
- Dictionary mappings = new();
-
- if (setMappings)
- {
- mappings.Add(backingColumnName, mappedName);
- }
-
- SourceDefinition table = new();
- table.Columns.Add(backingColumnName, new ColumnDefinition
- {
- SystemType = typeof(string)
- });
-
- DatabaseObject dbObject = new DatabaseTable() { TableDefinition = table };
-
- Entity configEntity = GenerateEmptyEntity("table") with { Mappings = mappings };
-
- ObjectTypeDefinitionNode od = SchemaConverter.GenerateObjectTypeDefinitionForDatabaseObject(
- "table",
- dbObject,
- configEntity,
- entities: new(new Dictionary()),
- rolesAllowedForEntity: GetRolesAllowedForEntity(),
- rolesAllowedForFields: GetFieldToRolesMap(columnName: table.Columns.First().Key));
-
- string errorMessage = "Object field representing database column has an unexpected name value.";
- if (expectMappedName)
- {
- Assert.AreEqual(mappedName, od.Fields[0].Name.Value, message: errorMessage);
- }
- else
- {
- Assert.AreEqual(backingColumnName, od.Fields[0].Name.Value, message: errorMessage);
- }
- }
-
- [TestMethod]
- public void PrimaryKeyColumnHasAppropriateDirective()
- {
- SourceDefinition table = new();
-
- string columnName = "columnName";
- table.Columns.Add(columnName, new ColumnDefinition
- {
- SystemType = typeof(string)
- });
- table.PrimaryKey.Add(columnName);
-
- DatabaseObject dbObject = new DatabaseTable() { TableDefinition = table };
-
- ObjectTypeDefinitionNode od = SchemaConverter.GenerateObjectTypeDefinitionForDatabaseObject(
- "table",
- dbObject,
- GenerateEmptyEntity("table"),
- new(new Dictionary()),
- rolesAllowedForEntity: GetRolesAllowedForEntity(),
- rolesAllowedForFields: GetFieldToRolesMap()
- );
-
- FieldDefinitionNode field = od.Fields.First(f => f.Name.Value == columnName);
- // Authorization directive implicitly created so actual count should be 1 + {expected number of directives}.
- Assert.AreEqual(2, field.Directives.Count);
- Assert.AreEqual(PrimaryKeyDirectiveType.DirectiveName, field.Directives[0].Name.Value);
- }
-
- [TestMethod]
- public void MultiplePrimaryKeysAllMappedWithDirectives()
- {
- SourceDefinition table = new();
-
- for (int i = 0; i < 5; i++)
- {
- string columnName = $"col{i}";
- table.Columns.Add(columnName, new ColumnDefinition { SystemType = typeof(string) });
- table.PrimaryKey.Add(columnName);
- }
-
- DatabaseObject dbObject = new DatabaseTable() { TableDefinition = table };
-
- ObjectTypeDefinitionNode od = SchemaConverter.GenerateObjectTypeDefinitionForDatabaseObject(
- "table",
- dbObject,
- GenerateEmptyEntity("table"),
- new(new Dictionary()),
- rolesAllowedForEntity: GetRolesAllowedForEntity(),
- rolesAllowedForFields: GetFieldToRolesMap()
- );
-
- foreach (FieldDefinitionNode field in od.Fields)
- {
- Assert.AreEqual(1, field.Directives.Count);
- Assert.AreEqual(PrimaryKeyDirectiveType.DirectiveName, field.Directives[0].Name.Value);
- }
- }
-
- [TestMethod]
- public void MultipleColumnsAllMapped()
- {
- int customColumnCount = 5;
-
- SourceDefinition table = new();
-
- for (int i = 0; i < customColumnCount; i++)
- {
- table.Columns.Add($"col{i}", new ColumnDefinition { SystemType = typeof(string) });
- }
-
- DatabaseObject dbObject = new DatabaseTable() { TableDefinition = table };
-
- ObjectTypeDefinitionNode od = SchemaConverter.GenerateObjectTypeDefinitionForDatabaseObject(
- "table",
- dbObject,
- GenerateEmptyEntity("table"),
- new(new Dictionary()),
- rolesAllowedForEntity: GetRolesAllowedForEntity(),
- rolesAllowedForFields: GetFieldToRolesMap(additionalColumns: customColumnCount)
- );
-
- Assert.AreEqual(table.Columns.Count, od.Fields.Count);
- }
-
- [DataTestMethod]
- [DataRow(typeof(string), STRING_TYPE)]
- [DataRow(typeof(byte), BYTE_TYPE)]
- [DataRow(typeof(short), SHORT_TYPE)]
- [DataRow(typeof(int), INT_TYPE)]
- [DataRow(typeof(long), LONG_TYPE)]
- [DataRow(typeof(float), SINGLE_TYPE)]
- [DataRow(typeof(double), FLOAT_TYPE)]
- [DataRow(typeof(decimal), DECIMAL_TYPE)]
- [DataRow(typeof(bool), BOOLEAN_TYPE)]
- [DataRow(typeof(DateTime), DATETIME_TYPE)]
- [DataRow(typeof(DateTimeOffset), DATETIME_TYPE)]
- [DataRow(typeof(byte[]), BYTEARRAY_TYPE)]
- [DataRow(typeof(Guid), UUID_TYPE)]
- [DataRow(typeof(TimeOnly), LOCALTIME_TYPE)]
- public void SystemTypeMapsToCorrectGraphQLType(Type systemType, string graphQLType)
- {
- SourceDefinition table = new();
-
- string columnName = "columnName";
- table.Columns.Add(columnName, new ColumnDefinition
- {
- SystemType = systemType
- });
-
- DatabaseObject dbObject = new DatabaseTable() { TableDefinition = table };
-
- ObjectTypeDefinitionNode od = SchemaConverter.GenerateObjectTypeDefinitionForDatabaseObject(
- "table",
- dbObject,
- GenerateEmptyEntity("table"),
- new(new Dictionary()),
- rolesAllowedForEntity: GetRolesAllowedForEntity(),
- rolesAllowedForFields: GetFieldToRolesMap()
- );
-
- FieldDefinitionNode field = od.Fields.First(f => f.Name.Value == columnName);
- Assert.AreEqual(graphQLType, field.Type.NamedType().Name.Value);
- }
-
- [TestMethod]
- public void NullColumnBecomesNullField()
- {
- SourceDefinition table = new();
-
- string columnName = "columnName";
- table.Columns.Add(columnName, new ColumnDefinition
- {
- SystemType = typeof(string),
- IsNullable = true,
- });
-
- DatabaseObject dbObject = new DatabaseTable() { TableDefinition = table };
-
- ObjectTypeDefinitionNode od = SchemaConverter.GenerateObjectTypeDefinitionForDatabaseObject(
- "table",
- dbObject,
- GenerateEmptyEntity("table"),
- new(new Dictionary()),
- rolesAllowedForEntity: GetRolesAllowedForEntity(),
- rolesAllowedForFields: GetFieldToRolesMap()
- );
-
- FieldDefinitionNode field = od.Fields.First(f => f.Name.Value == columnName);
- Assert.IsFalse(field.Type.IsNonNullType());
- }
-
- [TestMethod]
- public void NonNullColumnBecomesNonNullField()
- {
- SourceDefinition table = new();
-
- string columnName = "columnName";
- table.Columns.Add(columnName, new ColumnDefinition
- {
- SystemType = typeof(string),
- IsNullable = false,
- });
-
- DatabaseObject dbObject = new DatabaseTable() { TableDefinition = table };
-
- ObjectTypeDefinitionNode od = SchemaConverter.GenerateObjectTypeDefinitionForDatabaseObject(
- "table",
- dbObject,
- GenerateEmptyEntity("table"),
- new(new Dictionary()),
- rolesAllowedForEntity: GetRolesAllowedForEntity(),
- rolesAllowedForFields: GetFieldToRolesMap()
- );
-
- FieldDefinitionNode field = od.Fields.First(f => f.Name.Value == columnName);
- Assert.IsTrue(field.Type.IsNonNullType());
- }
-
- [TestMethod]
- public void ForeignKeyGeneratesObjectAndColumnField()
- {
- ObjectTypeDefinitionNode od = GenerateObjectWithRelationship(Cardinality.Many);
- Assert.AreEqual(3, od.Fields.Count);
- }
-
- [TestMethod]
- public void ForeignKeyObjectFieldNameAndTypeMatchesReferenceTable()
- {
-
- ObjectTypeDefinitionNode od = GenerateObjectWithRelationship(Cardinality.One);
- FieldDefinitionNode field
- = od.Fields.First(f => f.Name.Value != REF_COLNAME && f.Name.Value != COLUMN_NAME);
-
- Assert.AreEqual(FIELD_NAME_FOR_TARGET, field.Name.Value);
- Assert.AreEqual(TARGET_ENTITY, field.Type.NamedType().Name.Value);
- }
-
- [TestMethod]
- public void ForeignKeyFieldWillHaveRelationshipDirective()
- {
- ObjectTypeDefinitionNode od = GenerateObjectWithRelationship(Cardinality.One);
- FieldDefinitionNode field = od.Fields.First(f => f.Name.Value == FIELD_NAME_FOR_TARGET);
-
- Assert.AreEqual(1, field.Directives.Count);
- Assert.AreEqual(RelationshipDirectiveType.DirectiveName, field.Directives[0].Name.Value);
- }
-
- [TestMethod]
- public void CardinalityOfManyWillBeConnectionRelationship()
- {
- ObjectTypeDefinitionNode od = GenerateObjectWithRelationship(Cardinality.Many);
- FieldDefinitionNode field = od.Fields.First(f => f.Name.Value == FIELD_NAME_FOR_TARGET);
- Assert.IsTrue(QueryBuilder.IsPaginationType(field.Type.NamedType()));
- }
-
- [DataRow(true, DisplayName = "Test relationship field is nullable.")]
- [DataRow(false, DisplayName = "Test relationship field is not nullable.")]
- [TestMethod]
- public void ForeignKeyFieldHasCorrectNullability(bool isNullable)
- {
- ObjectTypeDefinitionNode od = GenerateObjectWithRelationship(Cardinality.Many, isNullableRelationship: isNullable);
- FieldDefinitionNode field = od.Fields.First(f => f.Name.Value == FIELD_NAME_FOR_TARGET);
- Assert.AreEqual(expected: isNullable, actual: field.Type is INullableTypeNode);
- }
-
- [TestMethod]
- public void WhenForeignKeyDefinedButNoRelationship_GraphQLWontModelIt()
- {
- SourceDefinition table = GenerateTableWithForeignKeyDefinition();
-
- Entity configEntity = GenerateEmptyEntity(SOURCE_ENTITY) with { Relationships = new() };
- Entity relationshipEntity = GenerateEmptyEntity(TARGET_ENTITY);
-
- DatabaseObject dbObject = new DatabaseTable() { TableDefinition = table };
-
- ObjectTypeDefinitionNode od =
- SchemaConverter.GenerateObjectTypeDefinitionForDatabaseObject(
- SOURCE_ENTITY,
- dbObject,
- configEntity,
- new(new Dictionary() { { TARGET_ENTITY, relationshipEntity } }),
- rolesAllowedForEntity: GetRolesAllowedForEntity(),
- rolesAllowedForFields: GetFieldToRolesMap()
- );
-
- Assert.AreEqual(2, od.Fields.Count);
- }
-
- ///
- /// Tests Object Type definition using config defined entity name is handled properly
- /// by schema converter:
- /// - entityName is not singularized, even if plural.
- /// - uses singular name if present and not null/empty.
- /// - Name is not formatted differently from config -> name is not converted to pascal/camel case.
- ///
- ///
- ///
- ///
- [DataTestMethod]
- [DataRow("entityName", "overrideName", "overrideName", DisplayName = "Singular name overrides top-level entity name")]
- [DataRow("my entity", "", "my entity", DisplayName = "Top-level entity name with space is not reformatted.")]
- [DataRow("entityName", null, "entityName", DisplayName = "Null singular name defers to top-level entity name")]
- [DataRow("entityName", "", "entityName", DisplayName = "Empty singular name defers to top-level entity name")]
- [DataRow("entities", null, "entities", DisplayName = "Plural top-level entity name and null singular name not singularized")]
- [DataRow("entities", "", "entities", DisplayName = "Plural top-level entity name and empty singular name not singularized")]
- public void SingularNamingRulesDeterminedByRuntimeConfig(string entityName, string singular, string expected)
- {
- SourceDefinition table = new();
-
- Entity configEntity = GenerateEmptyEntity(string.IsNullOrEmpty(singular) ? entityName : singular);
-
- DatabaseObject dbObject = new DatabaseTable() { TableDefinition = table };
-
- ObjectTypeDefinitionNode od = SchemaConverter.GenerateObjectTypeDefinitionForDatabaseObject(
- entityName,
- dbObject,
- configEntity,
- new(new Dictionary()),
- rolesAllowedForEntity: GetRolesAllowedForEntity(),
- rolesAllowedForFields: GetFieldToRolesMap()
- );
-
- Assert.AreEqual(expected, od.Name.Value);
- }
-
- ///
- /// When schema ObjectTypeDefinition is created,
- /// its fields contain the @authorize directive
- /// when rolesAllowedForFields() returns a role list
- ///
- [TestMethod]
- public void AutoGeneratedFieldHasDirectiveIndicatingSuch()
- {
- SourceDefinition table = new();
- string columnName = "columnName";
- table.Columns.Add(columnName, new ColumnDefinition
- {
- SystemType = typeof(string),
- IsNullable = false,
- IsAutoGenerated = true,
- IsReadOnly = true
- });
-
- DatabaseObject dbObject = new DatabaseTable() { TableDefinition = table };
-
- Entity configEntity = GenerateEmptyEntity("entity");
- ObjectTypeDefinitionNode od = SchemaConverter.GenerateObjectTypeDefinitionForDatabaseObject(
- "entity",
- dbObject,
- configEntity,
- new(new Dictionary()),
- rolesAllowedForEntity: GetRolesAllowedForEntity(),
- rolesAllowedForFields: GetFieldToRolesMap()
- );
-
- Assert.IsTrue(od.Fields[0].Directives.Any(d => d.Name.Value == AutoGeneratedDirectiveType.DirectiveName));
- }
-
- [DataTestMethod]
- [DataRow((byte)1, BYTE_TYPE, SyntaxKind.IntValue)]
- [DataRow((short)1, SHORT_TYPE, SyntaxKind.IntValue)]
- [DataRow(1, INT_TYPE, SyntaxKind.IntValue)]
- [DataRow(1L, LONG_TYPE, SyntaxKind.IntValue)]
- [DataRow("test", STRING_TYPE, SyntaxKind.StringValue)]
- [DataRow(true, BOOLEAN_TYPE, SyntaxKind.BooleanValue)]
- [DataRow(1.2f, SINGLE_TYPE, SyntaxKind.FloatValue)]
- [DataRow(1.2, FLOAT_TYPE, SyntaxKind.FloatValue)]
- [DataRow(1.2, DECIMAL_TYPE, SyntaxKind.FloatValue)]
- [DataRow("1999-01-08 10:23:54", DATETIME_TYPE, SyntaxKind.StringValue)]
- [DataRow("U3RyaW5neQ==", BYTEARRAY_TYPE, SyntaxKind.StringValue)]
- public void DefaultValueGetsSetOnDirective(object defaultValue, string fieldName, SyntaxKind kind)
- {
- if (fieldName == DECIMAL_TYPE)
- {
- defaultValue = decimal.Parse(defaultValue.ToString());
- }
- else if (fieldName == DATETIME_TYPE)
- {
- defaultValue = DateTime.Parse(defaultValue.ToString());
- }
- else if (fieldName == BYTEARRAY_TYPE)
- {
- defaultValue = Convert.FromBase64String(defaultValue.ToString());
- }
-
- SourceDefinition table = new();
- string columnName = "columnName";
- table.Columns.Add(columnName, new ColumnDefinition
- {
- SystemType = typeof(string),
- IsNullable = false,
- DefaultValue = defaultValue
- });
-
- DatabaseObject dbObject = new DatabaseTable() { TableDefinition = table };
-
- Entity configEntity = GenerateEmptyEntity("entity");
- ObjectTypeDefinitionNode od = SchemaConverter.GenerateObjectTypeDefinitionForDatabaseObject(
- "entity",
- dbObject,
- configEntity,
- new(new Dictionary()),
- rolesAllowedForEntity: GetRolesAllowedForEntity(),
- rolesAllowedForFields: GetFieldToRolesMap()
- );
-
- // @authorize directive is implicitly created so the count to compare to is 2
- Assert.AreEqual(2, od.Fields[0].Directives.Count);
- DirectiveNode directive = od.Fields[0].Directives[0];
- ObjectValueNode value = (ObjectValueNode)directive.Arguments[0].Value;
- Assert.AreEqual(fieldName, value.Fields[0].Name.Value);
- Assert.AreEqual(kind, value.Fields[0].Value.Kind);
- }
-
- ///
- /// Tests that each field on an ObjectTypeDefinition includes
- /// the expected @authorize directive.
- /// Given a the anonymous role provided by GetFieldToRolesMap()
- /// id - {anonymous, authenticated, role3, roleN}
- /// title - {authenticated}
- /// field3 - {role3, roleN}
- /// Adds directive @authorize(roles=[role1, role2, role3]).
- ///
- [DataTestMethod]
- [DataRow(new string[] { "authenticated" }, DisplayName = "One non-anonymous system role (authenticated) defined for field, @authorize directive added.")]
- [DataRow(new string[] { "authenticated", "role1" }, DisplayName = "Mixed role types (non-anonymous) roles defined for field, @authorize directive added.")]
- [DataRow(new string[] { "role1", "role2", "role3" }, DisplayName = "Multiple non-system roles defined for field, @authorize directive added.")]
- public void AutoGeneratedFieldHasAuthorizeDirective(string[] rolesForField)
- {
- SourceDefinition table = new();
- string columnName = "columnName";
- table.Columns.Add(columnName, new ColumnDefinition
- {
- SystemType = typeof(string),
- IsNullable = false,
- IsAutoGenerated = true,
- });
-
- DatabaseObject dbObject = new DatabaseTable() { TableDefinition = table };
-
- Entity configEntity = GenerateEmptyEntity("entity");
- ObjectTypeDefinitionNode od = SchemaConverter.GenerateObjectTypeDefinitionForDatabaseObject(
- "entity",
- dbObject,
- configEntity,
- new(new Dictionary()),
- rolesAllowedForEntity: GetRolesAllowedForEntity(),
- rolesAllowedForFields: GetFieldToRolesMap(rolesForField: rolesForField)
- );
-
- // Ensures all fields added have the appropriate @authorize directive.
- Assert.IsTrue(od.Fields.All(field => field.Directives.Any(d => d.Name.Value == GraphQLUtils.AUTHORIZE_DIRECTIVE)));
- }
-
- ///
- /// Tests that each field on an entity's ObjectTypeDefinition does not include
- /// an @authorize directive.
- /// Given a set of roles provided by GetFieldToRolesMap()
- /// id - {anonymous, authenticated, role3, roleN}
- /// title - {authenticated}
- /// field3 - {role3, roleN}
- /// Adds directive @authorize(roles=[role1, role2, role3]).
- ///
- [DataTestMethod]
- [DataRow(new string[] { "anonymous" }, DisplayName = "Anonymous is only role for field")]
- [DataRow(new string[] { "anonymous", "Role1" }, DisplayName = "Anonymous is 1 of many roles for field")]
- [DataRow(new string[] { "authenticated", "anonymous" }, DisplayName = "Anonymous and authenticated are present and randomly ordered, anonymous wins.")]
- public void FieldWithAnonymousAccessHasNoAuthorizeDirective(string[] rolesForField)
- {
- SourceDefinition table = new();
- string columnName = "columnName";
- table.Columns.Add(columnName, new ColumnDefinition
- {
- SystemType = typeof(string),
- IsNullable = false,
- IsAutoGenerated = true,
- });
-
- Entity configEntity = GenerateEmptyEntity("entity");
- DatabaseObject dbObject = new DatabaseTable() { TableDefinition = table };
-
- ObjectTypeDefinitionNode od = SchemaConverter.GenerateObjectTypeDefinitionForDatabaseObject(
- "entity",
- dbObject,
- configEntity,
- new(new Dictionary()),
- rolesAllowedForEntity: GetRolesAllowedForEntity(),
- rolesAllowedForFields: GetFieldToRolesMap(rolesForField: rolesForField)
- );
-
- // Ensures no field has the @authorize directive.
- Assert.IsFalse(od.Fields.All(field => field.Directives.Any(d => d.Name.Value == GraphQLUtils.AUTHORIZE_DIRECTIVE)),
- message: "@authorize directive must not be present for field with anonymous access permissions.");
- }
-
- ///
- /// Tests that an entity's ObjectTypeDefinition only includes an
- /// @authorize directive when anonymous is not a defined role.
- /// Given a set of roles provided by GetRolesAllowedForEntity()
- /// {anonymous, authenticated, role3, roleN} -> no @authorize directive as anonymous access required.
- /// {authenticated} -> @authorize directive included with listed roles.
- /// {role3, roleN} -> @authorize directive included with listed roles.
- ///
- [DataTestMethod]
- [DataRow(new string[] { "anonymous" }, false, DisplayName = "Anonymous is only role for field, no authorize directive.")]
- [DataRow(new string[] { "anonymous", "role1" }, false, DisplayName = "Anonymous is 1 of many roles for field, no authorize directive.")]
- [DataRow(new string[] { "authenticated", "anonymous" }, false, DisplayName = "Anonymous and authenticated are present and randomly ordered, anonymous wins.")]
- [DataRow(new string[] { "authenticated" }, true, DisplayName = "Authorize directive present for listed roles.")]
- [DataRow(new string[] { "role1", "role2" }, true, DisplayName = "Authorize directive present for listed roles.")]
- public void EntityObjectTypeDefinition_AuthorizeDirectivePresence(string[] roles, bool authorizeDirectiveExpected)
- {
- SourceDefinition table = new();
- string columnName = "columnName";
- table.Columns.Add(columnName, new ColumnDefinition
- {
- SystemType = typeof(string),
- IsNullable = false,
- IsAutoGenerated = true,
- });
-
- DatabaseObject dbObject = new DatabaseTable() { TableDefinition = table };
-
- Entity configEntity = GenerateEmptyEntity("entity");
- ObjectTypeDefinitionNode od = SchemaConverter.GenerateObjectTypeDefinitionForDatabaseObject(
- "entity",
- dbObject,
- configEntity,
- new(new Dictionary()),
- rolesAllowedForEntity: roles,
- rolesAllowedForFields: GetFieldToRolesMap(rolesForField: roles)
- );
-
- // Prepares error message, only used if assertion fails.
- string errorMessage = !authorizeDirectiveExpected ? $"@authorize directive must not be present for field with anonymous access permissions."
- : $"@authorize directive must not be present for field with anonymous access permissions.";
-
- // Ensures no field has the @authorize directive if it is NOT authorizeDirectiveExpected
- Assert.AreEqual(expected: authorizeDirectiveExpected, actual: od.Directives.Any(d => d.Name.Value == GraphQLUtils.AUTHORIZE_DIRECTIVE),
- message: errorMessage);
- }
-
- ///
- /// Tests that an entity's ObjectTypeDefinition only includes an
- /// @authorize directive when anonymous is not a defined role.
- /// Tests that an entity's ObjectTypeDefinition's fields only includes an
- /// @authorize directive when anonymous is not a defined role.
- ///
- /// Roles allowed for entity.
- /// Roles allowed for fields.
- /// Directive expected to be present on entity.
- /// Directive expected to be present on fields.
- [DataTestMethod]
- [DataRow(new string[] { "anonymous" }, new string[] { "anonymous" }, false, false, DisplayName = "No authorize directive on entity or fields.")]
- [DataRow(new string[] { "anonymous", "role1" }, new string[] { "role1" }, false, true, DisplayName = "No authorize directive on entity, but it is on fields.")]
- [DataRow(new string[] { "authenticated", "anonymous" }, new string[] { "anonymous" }, false, false, DisplayName = "No Authorize directive on entity or fields, mixed.")]
- [DataRow(new string[] { "authenticated" }, new string[] { "role1" }, true, true, DisplayName = "Authorize directive on entity and on fields.")]
- [DataRow(new string[] { "authenticated" }, new string[] { "anonymous" }, true, false, DisplayName = "Authorize Directive on entity, not on fields")]
- public void EntityObjectTypeDefinition_AuthorizeDirectivePresenceMixed(string[] rolesForEntity, string[] rolesForFields, bool authorizeDirectiveExpectedEntity, bool authorizeDirectiveExpectedFields)
- {
- SourceDefinition table = new();
- string columnName = "columnName";
- table.Columns.Add(columnName, new ColumnDefinition
- {
- SystemType = typeof(string),
- IsNullable = false,
- IsAutoGenerated = true,
- });
-
- Entity configEntity = GenerateEmptyEntity("entity");
- DatabaseObject dbObject = new DatabaseTable() { TableDefinition = table };
-
- ObjectTypeDefinitionNode od = SchemaConverter.GenerateObjectTypeDefinitionForDatabaseObject(
- "entity",
- dbObject,
- configEntity,
- new(new Dictionary()),
- rolesAllowedForEntity: rolesForEntity,
- rolesAllowedForFields: GetFieldToRolesMap(rolesForField: rolesForFields)
- );
-
- // Prepares error message, only used if assertion fails.
- string entityErrorMessage = !authorizeDirectiveExpectedEntity ? $"@authorize directive must not be present on entity with anonymous access permissions."
- : $"@authorize directive must be present on entity with anonymous access permissions.";
-
- // Ensures no field has the @authorize directive if it is NOT authorizeDirectiveExpected
- Assert.AreEqual(expected: authorizeDirectiveExpectedEntity, actual: od.Directives.Any(d => d.Name.Value == GraphQLUtils.AUTHORIZE_DIRECTIVE),
- message: entityErrorMessage);
-
- // Prepares error message, only used if assertion fails.
- string fieldErrorMessage = !authorizeDirectiveExpectedFields ? $"@authorize directive must not be present for field with anonymous access permissions."
- : $"@authorize directive must be present for field with anonymous access permissions.";
-
- // Ensures no field has the @authorize directive if it is NOT authorizeDirectiveExpected
- Assert.AreEqual(expected: authorizeDirectiveExpectedFields, actual: od.Fields.All(field => field.Directives.Any(d => d.Name.Value == GraphQLUtils.AUTHORIZE_DIRECTIVE)),
- message: fieldErrorMessage);
- }
-
- ///
- /// Mocks a list of roles for the Schema Converter Tests
- /// Defaults to authenticated
- ///
- /// Collection of roles
- public static IEnumerable GetRolesAllowedForEntity()
- {
- return new List() { "authenticated" };
- }
-
- ///
- /// Mocks FieldToRoleMap for Schema Converter Tests
- /// For tests that require arbitrary number of columns,
- /// the additionalColumns argument should be used to define
- /// the desired number of columns.
- /// Default is 0 and results in the two constant fields created that are
- /// relevant to most tests in SchemaConverterTests.
- ///
- /// number of columns/fields to generate
- /// custom column name
- /// Key Value Map of Field to Roles
- public static IDictionary> GetFieldToRolesMap(int additionalColumns = 0, string columnName = "", IEnumerable rolesForField = null)
- {
- Dictionary> fieldToRolesMap = new();
-
- if (rolesForField is null)
- {
- rolesForField = GetRolesAllowedForEntity();
- }
-
- if (additionalColumns != 0)
- {
- for (int columnNumber = 0; columnNumber < additionalColumns; columnNumber++)
- {
- fieldToRolesMap.Add("col" + columnNumber.ToString(), rolesForField);
- }
- }
- else if (!string.IsNullOrEmpty(columnName))
- {
- fieldToRolesMap.Add(columnName, rolesForField);
- }
- else
- {
- fieldToRolesMap.Add(COLUMN_NAME, rolesForField);
- fieldToRolesMap.Add(REF_COLNAME, rolesForField);
- }
-
- return fieldToRolesMap;
- }
-
- public static Entity GenerateEmptyEntity(string entityName)
- {
- return new Entity(
- Source: new($"{SCHEMA_NAME}.{TABLE_NAME}", EntitySourceType.Table, null, null),
- Fields: null,
- Rest: new(Enabled: true),
- GraphQL: new(entityName, ""),
- Permissions: Array.Empty(),
- Relationships: new(),
- Mappings: new()
- );
- }
-
- private static ObjectTypeDefinitionNode GenerateObjectWithRelationship(Cardinality cardinality, bool isNullableRelationship = false)
- {
- SourceDefinition table = GenerateTableWithForeignKeyDefinition(isNullableRelationship);
-
- Dictionary relationships =
- new()
- {
- {
- FIELD_NAME_FOR_TARGET,
- new EntityRelationship(
- cardinality,
- TARGET_ENTITY,
- SourceFields: null,
- TargetFields: null,
- LinkingObject: null,
- LinkingSourceFields: null,
- LinkingTargetFields: null)
- }
- };
- Entity configEntity = GenerateEmptyEntity(SOURCE_ENTITY) with { Relationships = relationships };
- Entity relationshipEntity = GenerateEmptyEntity(TARGET_ENTITY);
-
- DatabaseObject dbObject = new DatabaseTable()
- { SchemaName = SCHEMA_NAME, Name = TABLE_NAME, TableDefinition = table };
-
- return SchemaConverter.GenerateObjectTypeDefinitionForDatabaseObject(
- SOURCE_ENTITY,
- dbObject,
- configEntity, new(new Dictionary() { { TARGET_ENTITY, relationshipEntity } }),
- rolesAllowedForEntity: GetRolesAllowedForEntity(),
- rolesAllowedForFields: GetFieldToRolesMap()
- );
- }
-
- ///
- /// Generates a table with a foreign key relationship added.
- ///
- /// whether the foreign key column can be null
- ///
- private static SourceDefinition GenerateTableWithForeignKeyDefinition(bool isNullable = false)
- {
- SourceDefinition table = new();
- table.Columns.Add(COLUMN_NAME, new ColumnDefinition
- {
- SystemType = typeof(string),
- IsNullable = false,
- });
-
- RelationshipMetadata
- relationshipMetadata = new();
-
- table.SourceEntityRelationshipMap.Add(SOURCE_ENTITY, relationshipMetadata);
- List fkDefinitions = new()
- {
- new ForeignKeyDefinition()
- {
- Pair = new()
- {
- ReferencingDbTable = new DatabaseTable(SCHEMA_NAME, TABLE_NAME),
- ReferencedDbTable = new DatabaseTable(SCHEMA_NAME, REFERENCED_TABLE)
- },
- ReferencingColumns = new List { REF_COLNAME },
- ReferencedColumns = new List { REFD_COLNAME }
- }
- };
- relationshipMetadata.TargetEntityToFkDefinitionMap.Add(TARGET_ENTITY, fkDefinitions);
-
- table.Columns.Add(REF_COLNAME, new ColumnDefinition
- {
- SystemType = typeof(int),
- IsNullable = isNullable
- });
-
- return table;
- }
-
- ///
- /// Tests generation of aggregation type for an entity with numeric fields.
- /// Verifies that all numeric operations (max, min, avg, sum, count) are created
- /// with correct return types and arguments.
- ///
- [TestMethod]
- [TestCategory("Schema Converter - Aggregation Type")]
- public void GenerateAggregationTypeForEntity_WithNumericFields_CreatesAllOperations()
- {
- string gql = @"
-type Book @model(name:""Book"") {
- id: ID!
- price: Float!
- rating: Int
- pages: Int!
-}";
-
- DocumentNode root = Utf8GraphQLParser.Parse(gql);
- ObjectTypeDefinitionNode node = root.Definitions[0] as ObjectTypeDefinitionNode;
-
- ObjectTypeDefinitionNode aggregationType = SchemaConverter.GenerateAggregationTypeForEntity("Book", node);
-
- Assert.AreEqual("BookAggregations", aggregationType.Name.Value);
- Assert.AreEqual(5, aggregationType.Fields.Count, "Should have max, min, avg, sum, and count operations");
-
- // Verify all operations exist with correct return types
- Dictionary operations = aggregationType.Fields.ToDictionary(f => f.Name.Value, f => f.Type.NamedType().Name.Value);
- Assert.AreEqual("Float", operations["max"]);
- Assert.AreEqual("Float", operations["min"]);
- Assert.AreEqual("Float", operations["avg"]);
- Assert.AreEqual("Float", operations["sum"]);
- Assert.AreEqual("Int", operations["count"]);
-
- // Verify field arguments and their specific filter input types
- FieldDefinitionNode maxField = aggregationType.Fields.First(f => f.Name.Value == "max");
- Assert.AreEqual(3, maxField.Arguments.Count, "Each operation should have field, having, and distinct arguments");
- Assert.AreEqual("BookNumericAggregateFields", maxField.Arguments[0].Type.NamedType().Name.Value);
- Assert.AreEqual("FloatFilterInput", maxField.Arguments[1].Type.NamedType().Name.Value, "Should use FloatFilterInput for mixed Float/Int fields");
- Assert.AreEqual("Boolean", maxField.Arguments[2].Type.NamedType().Name.Value);
- }
-
- ///
- /// Tests generation of aggregation type for an entity with only integer fields.
- /// Verifies that the filter input type is specifically IntFilterInput when all
- /// numeric fields are integers.
- ///
- [TestMethod]
- [TestCategory("Schema Converter - Aggregation Type")]
- public void GenerateAggregationTypeForEntity_WithSingleNumericType_UsesSpecificFilterInput()
- {
- string gql = @"
-type Book @model(name:""Book"") {
- id: ID!
- pages: Int!
- chapter_count: Int
-}";
-
- DocumentNode root = Utf8GraphQLParser.Parse(gql);
- ObjectTypeDefinitionNode node = root.Definitions[0] as ObjectTypeDefinitionNode;
-
- ObjectTypeDefinitionNode aggregationType = SchemaConverter.GenerateAggregationTypeForEntity("Book", node);
-
- // Verify that operations use IntFilterInput since all numeric fields are Int
- FieldDefinitionNode maxField = aggregationType.Fields.First(f => f.Name.Value == "max");
- Assert.AreEqual("IntFilterInput", maxField.Arguments[1].Type.NamedType().Name.Value, "Should use IntFilterInput when all numeric fields are Int");
- }
-
- ///
- /// Tests generation of aggregation type for an entity with no numeric fields.
- /// Verifies that an empty type is created when there are no fields eligible
- /// for numeric aggregation.
- ///
- [TestMethod]
- [TestCategory("Schema Converter - Aggregation Type")]
- public void GenerateAggregationTypeForEntity_WithNoNumericFields_CreatesEmptyType()
- {
- string gql = @"
-type Book @model(name:""Book"") {
- id: ID!
- title: String!
- isPublished: Boolean
-}";
-
- DocumentNode root = Utf8GraphQLParser.Parse(gql);
- ObjectTypeDefinitionNode node = root.Definitions[0] as ObjectTypeDefinitionNode;
-
- ObjectTypeDefinitionNode aggregationType = SchemaConverter.GenerateAggregationTypeForEntity("Book", node);
-
- Assert.AreEqual("BookAggregations", aggregationType.Name.Value);
- Assert.AreEqual(0, aggregationType.Fields.Count, "Should have no aggregation operations");
- }
-
- ///
- /// Tests generation of aggregation type for an entity with mixed field types.
- /// Verifies that only numeric fields are included in the aggregation operations
- /// while other types (string, boolean, etc.) are excluded.
- ///
- [TestMethod]
- [TestCategory("Schema Converter - Aggregation Type")]
- public void GenerateAggregationTypeForEntity_WithMixedFields_OnlyIncludesNumericOperations()
- {
- string gql = @"
-type Book @model(name:""Book"") {
- id: ID!
- title: String
- price: Float!
- isPublished: Boolean
- rating: Int
-}";
-
- DocumentNode root = Utf8GraphQLParser.Parse(gql);
- ObjectTypeDefinitionNode node = root.Definitions[0] as ObjectTypeDefinitionNode;
-
- ObjectTypeDefinitionNode aggregationType = SchemaConverter.GenerateAggregationTypeForEntity("Book", node);
-
- Assert.AreEqual("BookAggregations", aggregationType.Name.Value);
- Assert.AreEqual(5, aggregationType.Fields.Count, "Should have all operations for numeric fields only");
-
- // Verify the field argument only includes numeric fields
- InputValueDefinitionNode fieldArg = aggregationType.Fields.First().Arguments[0];
- Assert.AreEqual("BookNumericAggregateFields", fieldArg.Type.NamedType().Name.Value);
- }
- }
-}
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Azure.DataApiBuilder.Config.DatabasePrimitives;
+using Azure.DataApiBuilder.Config.ObjectModel;
+using Azure.DataApiBuilder.Service.GraphQLBuilder;
+using Azure.DataApiBuilder.Service.GraphQLBuilder.Directives;
+using Azure.DataApiBuilder.Service.GraphQLBuilder.Queries;
+using Azure.DataApiBuilder.Service.GraphQLBuilder.Sql;
+using HotChocolate.Language;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using static Azure.DataApiBuilder.Service.GraphQLBuilder.GraphQLTypes.SupportedHotChocolateTypes;
+
+namespace Azure.DataApiBuilder.Service.Tests.GraphQLBuilder.Sql
+{
+ [TestClass]
+ [TestCategory("GraphQL Schema Builder")]
+ public class SchemaConverterTests
+ {
+ const string SCHEMA_NAME = "dbo";
+ const string TABLE_NAME = "tableName";
+ const string COLUMN_NAME = "columnName";
+ const string REF_COLNAME = "ref_col_in_source";
+ const string SOURCE_ENTITY = "sourceEntity";
+ const string FIELD_NAME_FOR_TARGET = "target";
+
+ const string TARGET_ENTITY = "TargetEntity";
+ const string REFERENCED_TABLE = "fkTable";
+ const string REFD_COLNAME = "fk_col";
+
+ [DataTestMethod]
+ [DataRow("test", "test")]
+ [DataRow("Test", "Test")]
+ [DataRow("T_est", "T_est")]
+ [DataRow("Test1", "Test1")]
+ public void EntityNameBecomesObjectName(string entityName, string expected)
+ {
+ DatabaseObject dbObject = new DatabaseTable { TableDefinition = new SourceDefinition() };
+
+ ObjectTypeDefinitionNode od = SchemaConverter.GenerateObjectTypeDefinitionForDatabaseObject(
+ entityName,
+ dbObject,
+ GenerateEmptyEntity(entityName),
+ new(new Dictionary()),
+ rolesAllowedForEntity: GetRolesAllowedForEntity(),
+ rolesAllowedForFields: GetFieldToRolesMap()
+ );
+
+ Assert.AreEqual(expected, od.Name.Value);
+ }
+
+ ///
+ /// Validates that schema generation does not modify names.
+ ///
+ ///
+ ///
+ [DataTestMethod]
+ [DataRow("test", "test")]
+ [DataRow("Test", "Test")]
+ public void ColumnNameBecomesFieldName(string columnName, string expected)
+ {
+ SourceDefinition table = new();
+ table.Columns.Add(columnName, new ColumnDefinition
+ {
+ SystemType = typeof(string)
+ });
+
+ DatabaseObject dbObject = new DatabaseTable() { TableDefinition = table };
+
+ ObjectTypeDefinitionNode od = SchemaConverter.GenerateObjectTypeDefinitionForDatabaseObject(
+ "table",
+ dbObject,
+ GenerateEmptyEntity("table"),
+ new(new Dictionary()),
+ rolesAllowedForEntity: GetRolesAllowedForEntity(),
+ rolesAllowedForFields: GetFieldToRolesMap(columnName: table.Columns.First().Key)
+ );
+
+ Assert.AreEqual(expected, od.Fields[0].Name.Value);
+ }
+
+ ///
+ /// Tests that an Entity object's mapping configuration is utilized in the schema generator
+ /// by checking that mapped column values are used for field names instead of backing column names.
+ ///
+ /// Whether to add mapping entries to the mappings collection.
+ /// Name of database column.
+ /// Configured alternative (mapped) name of column to be used in REST/GraphQL endpoints.
+ /// Whether GraphQL object field name should equal the mapped column name provided.
+ [DataTestMethod]
+ [DataRow(true, "__typename", "typename", true, DisplayName = "Mapped column name fixes GraphQL introspection naming violation. ")]
+ [DataRow(false, "typename", "mappedtypename", false, DisplayName = "Mapped column name ")]
+ public void FieldNameMatchesMappedValue(bool setMappings, string backingColumnName, string mappedName, bool expectMappedName)
+ {
+ Dictionary mappings = new();
+
+ if (setMappings)
+ {
+ mappings.Add(backingColumnName, mappedName);
+ }
+
+ SourceDefinition table = new();
+ table.Columns.Add(backingColumnName, new ColumnDefinition
+ {
+ SystemType = typeof(string)
+ });
+
+ DatabaseObject dbObject = new DatabaseTable() { TableDefinition = table };
+
+ Entity configEntity = GenerateEmptyEntity("table") with { Mappings = mappings };
+
+ ObjectTypeDefinitionNode od = SchemaConverter.GenerateObjectTypeDefinitionForDatabaseObject(
+ "table",
+ dbObject,
+ configEntity,
+ entities: new(new Dictionary()),
+ rolesAllowedForEntity: GetRolesAllowedForEntity(),
+ rolesAllowedForFields: GetFieldToRolesMap(columnName: table.Columns.First().Key));
+
+ string errorMessage = "Object field representing database column has an unexpected name value.";
+ if (expectMappedName)
+ {
+ Assert.AreEqual(mappedName, od.Fields[0].Name.Value, message: errorMessage);
+ }
+ else
+ {
+ Assert.AreEqual(backingColumnName, od.Fields[0].Name.Value, message: errorMessage);
+ }
+ }
+
+ [TestMethod]
+ public void PrimaryKeyColumnHasAppropriateDirective()
+ {
+ SourceDefinition table = new();
+
+ string columnName = "columnName";
+ table.Columns.Add(columnName, new ColumnDefinition
+ {
+ SystemType = typeof(string)
+ });
+ table.PrimaryKey.Add(columnName);
+
+ DatabaseObject dbObject = new DatabaseTable() { TableDefinition = table };
+
+ ObjectTypeDefinitionNode od = SchemaConverter.GenerateObjectTypeDefinitionForDatabaseObject(
+ "table",
+ dbObject,
+ GenerateEmptyEntity("table"),
+ new(new Dictionary