From e850ead77ec3d8423562b009d9f616e7a92c2ff9 Mon Sep 17 00:00:00 2001 From: Ayush Agarwal <34566234+ayush3797@users.noreply.github.com> Date: Sat, 10 Feb 2024 03:19:10 +0530 Subject: [PATCH 1/8] Adding validation to disallow multiple relationships between same source and target entities (#1929) ## Why make this change? Currently, we allow defining multiple relationships between the same source and target entities. A user is allowed to define multiple relationships each with different source fields/target fields and even different cardinalities. This is not only logically incorrect and difficult to make sense out of, but will also pose a problem in the functioning of nested mutations. ## What is this change? Added the above explained validation in `development` mode when `GraphQL is enabled` for the source entity.. ## How was this tested? - Added unit test via `RuntimeConfigValidator.TestMultipleRelationshipsBetweenSourceAndTargetEntities`. --- .../Configurations/RuntimeConfigValidator.cs | 18 ++- .../Unittests/ConfigValidationUnitTests.cs | 104 ++++++++++++++++++ 2 files changed, 121 insertions(+), 1 deletion(-) diff --git a/src/Core/Configurations/RuntimeConfigValidator.cs b/src/Core/Configurations/RuntimeConfigValidator.cs index 328223c9ce..57bdef72cc 100644 --- a/src/Core/Configurations/RuntimeConfigValidator.cs +++ b/src/Core/Configurations/RuntimeConfigValidator.cs @@ -810,8 +810,24 @@ public void ValidateRelationshipsInConfig(RuntimeConfig runtimeConfig, IMetadata string databaseName = runtimeConfig.GetDataSourceNameFromEntityName(entityName); ISqlMetadataProvider sqlMetadataProvider = sqlMetadataProviderFactory.GetMetadataProvider(databaseName); - foreach (EntityRelationship relationship in entity.Relationships!.Values) + // Dictionary to store mapping from target entity's name to relationship name. Whenever we encounter that we + // are getting more than 1 entry for a target entity, we throw a validation error as it indicates the user has + // defined multiple relationships between the same source and target entities. + Dictionary targetEntityNameToRelationshipName = new(); + foreach ((string relationshipName, EntityRelationship relationship) in entity.Relationships!) { + string targetEntityName = relationship.TargetEntity; + if (targetEntityNameToRelationshipName.TryGetValue(targetEntityName, out string? duplicateRelationshipName)) + { + HandleOrRecordException(new DataApiBuilderException( + message: $"Defining multiple relationships: {duplicateRelationshipName}, {relationshipName} between source entity: {entityName} and target entity: {targetEntityName} is not supported.", + statusCode: HttpStatusCode.ServiceUnavailable, + subStatusCode: DataApiBuilderException.SubStatusCodes.ConfigValidationError)); + } + + // Add entry for this relationship to the dictionary tracking all the relationships for this entity. + targetEntityNameToRelationshipName[targetEntityName] = relationshipName; + // Validate if entity referenced in relationship is defined in the config. if (!runtimeConfig.Entities.ContainsKey(relationship.TargetEntity)) { diff --git a/src/Service.Tests/Unittests/ConfigValidationUnitTests.cs b/src/Service.Tests/Unittests/ConfigValidationUnitTests.cs index 6d6065b866..b367131d55 100644 --- a/src/Service.Tests/Unittests/ConfigValidationUnitTests.cs +++ b/src/Service.Tests/Unittests/ConfigValidationUnitTests.cs @@ -336,6 +336,110 @@ public void TestAddingRelationshipWithDisabledGraphQL() Assert.AreEqual(HttpStatusCode.ServiceUnavailable, ex.StatusCode); } + /// + /// Testing the RuntimeCOnfigValidator.ValidateRelationshipsInConfig() method to ensure that we throw a validation error + /// when GraphQL is enabled on the source entity and the user defines multiple relationships between the same source and target entities. + /// + [DataTestMethod] + [DataRow(true, DisplayName = "Validate that an exception is thrown when GQL is enabled and user defines multiple relationship between source and target entities.")] + [DataRow(false, DisplayName = "Validate that no exception is thrown when GQL is disabled and user defines multiple relationship between source and target entities.")] + public void TestMultipleRelationshipsBetweenSourceAndTargetEntities(bool isGQLEnabledForSource) + { + string sourceEntityName = "SourceEntity", targetEntityName = "TargetEntity"; + + // Create relationship between source and target entities. + EntityRelationship relationship = new( + Cardinality: Cardinality.One, + TargetEntity: targetEntityName, + SourceFields: new string[] { "abc" }, + TargetFields: new string[] { "xyz" }, + LinkingObject: null, + LinkingSourceFields: null, + LinkingTargetFields: null + ); + + // Add another relationship between the same source and target entities. + EntityRelationship duplicateRelationship = new( + Cardinality: Cardinality.Many, + TargetEntity: targetEntityName, + SourceFields: null, + TargetFields: null, + LinkingObject: null, + LinkingSourceFields: null, + LinkingTargetFields: null + ); + + string relationshipName = "relationship", duplicateRelationshipName = "duplicateRelationship"; + Dictionary relationshipMap = new() + { + { relationshipName, relationship }, + { duplicateRelationshipName, duplicateRelationship } + }; + + // Creating source entity with enabled graphQL + Entity sourceEntity = GetSampleEntityUsingSourceAndRelationshipMap( + source: "TEST_SOURCE1", + relationshipMap: relationshipMap, + graphQLDetails: new(Singular: "", Plural: "", Enabled: isGQLEnabledForSource) + ); + + // Creating target entity. + Entity targetEntity = GetSampleEntityUsingSourceAndRelationshipMap( + source: "TEST_SOURCE2", + relationshipMap: null, + graphQLDetails: new("", "", true) + ); + + Dictionary entityMap = new() + { + { sourceEntityName, sourceEntity }, + { targetEntityName, targetEntity } + }; + + RuntimeConfig runtimeConfig = new( + Schema: "UnitTestSchema", + DataSource: new DataSource(DatabaseType: DatabaseType.MSSQL, "", Options: null), + Runtime: new( + Rest: new(), + GraphQL: new(), + Host: new(null, null) + ), + Entities: new(entityMap) + ); + + RuntimeConfigValidator configValidator = InitializeRuntimeConfigValidator(); + Mock _sqlMetadataProvider = new(); + Dictionary mockDictionaryForEntityDatabaseObject = new() + { + { + sourceEntityName, + new DatabaseTable("dbo", "TEST_SOURCE1") + }, + + { + targetEntityName, + new DatabaseTable("dbo", "TEST_SOURCE2") + } + }; + + _sqlMetadataProvider.Setup(x => x.EntityToDatabaseObject).Returns(mockDictionaryForEntityDatabaseObject); + Mock _metadataProviderFactory = new(); + _metadataProviderFactory.Setup(x => x.GetMetadataProvider(It.IsAny())).Returns(_sqlMetadataProvider.Object); + + if (isGQLEnabledForSource) + { + // Assert for expected exception. + DataApiBuilderException ex = Assert.ThrowsException(() => + configValidator.ValidateRelationshipsInConfig(runtimeConfig, _metadataProviderFactory.Object)); + Assert.AreEqual($"Defining multiple relationships: {relationshipName}, {duplicateRelationshipName} between source entity: {sourceEntityName} and target entity: {targetEntityName} is not supported.", ex.Message); + Assert.AreEqual(HttpStatusCode.ServiceUnavailable, ex.StatusCode); + } + else + { + configValidator.ValidateRelationshipsInConfig(runtimeConfig, _metadataProviderFactory.Object); + } + } + /// /// Test method to check that an exception is thrown when LinkingObject was provided /// while either LinkingSourceField or SourceField is null, and either targetFields or LinkingTargetField is null. From adcdeab9c816b572cf5c7aea561168e405d36d9e Mon Sep 17 00:00:00 2001 From: Ayush Agarwal Date: Fri, 16 Feb 2024 13:15:24 +0530 Subject: [PATCH 2/8] reverting exception throwing on multiple relationships between same entities --- .../Configurations/RuntimeConfigValidator.cs | 18 +-- .../Unittests/ConfigValidationUnitTests.cs | 104 ------------------ 2 files changed, 1 insertion(+), 121 deletions(-) diff --git a/src/Core/Configurations/RuntimeConfigValidator.cs b/src/Core/Configurations/RuntimeConfigValidator.cs index 57bdef72cc..328223c9ce 100644 --- a/src/Core/Configurations/RuntimeConfigValidator.cs +++ b/src/Core/Configurations/RuntimeConfigValidator.cs @@ -810,24 +810,8 @@ public void ValidateRelationshipsInConfig(RuntimeConfig runtimeConfig, IMetadata string databaseName = runtimeConfig.GetDataSourceNameFromEntityName(entityName); ISqlMetadataProvider sqlMetadataProvider = sqlMetadataProviderFactory.GetMetadataProvider(databaseName); - // Dictionary to store mapping from target entity's name to relationship name. Whenever we encounter that we - // are getting more than 1 entry for a target entity, we throw a validation error as it indicates the user has - // defined multiple relationships between the same source and target entities. - Dictionary targetEntityNameToRelationshipName = new(); - foreach ((string relationshipName, EntityRelationship relationship) in entity.Relationships!) + foreach (EntityRelationship relationship in entity.Relationships!.Values) { - string targetEntityName = relationship.TargetEntity; - if (targetEntityNameToRelationshipName.TryGetValue(targetEntityName, out string? duplicateRelationshipName)) - { - HandleOrRecordException(new DataApiBuilderException( - message: $"Defining multiple relationships: {duplicateRelationshipName}, {relationshipName} between source entity: {entityName} and target entity: {targetEntityName} is not supported.", - statusCode: HttpStatusCode.ServiceUnavailable, - subStatusCode: DataApiBuilderException.SubStatusCodes.ConfigValidationError)); - } - - // Add entry for this relationship to the dictionary tracking all the relationships for this entity. - targetEntityNameToRelationshipName[targetEntityName] = relationshipName; - // Validate if entity referenced in relationship is defined in the config. if (!runtimeConfig.Entities.ContainsKey(relationship.TargetEntity)) { diff --git a/src/Service.Tests/Unittests/ConfigValidationUnitTests.cs b/src/Service.Tests/Unittests/ConfigValidationUnitTests.cs index b367131d55..6d6065b866 100644 --- a/src/Service.Tests/Unittests/ConfigValidationUnitTests.cs +++ b/src/Service.Tests/Unittests/ConfigValidationUnitTests.cs @@ -336,110 +336,6 @@ public void TestAddingRelationshipWithDisabledGraphQL() Assert.AreEqual(HttpStatusCode.ServiceUnavailable, ex.StatusCode); } - /// - /// Testing the RuntimeCOnfigValidator.ValidateRelationshipsInConfig() method to ensure that we throw a validation error - /// when GraphQL is enabled on the source entity and the user defines multiple relationships between the same source and target entities. - /// - [DataTestMethod] - [DataRow(true, DisplayName = "Validate that an exception is thrown when GQL is enabled and user defines multiple relationship between source and target entities.")] - [DataRow(false, DisplayName = "Validate that no exception is thrown when GQL is disabled and user defines multiple relationship between source and target entities.")] - public void TestMultipleRelationshipsBetweenSourceAndTargetEntities(bool isGQLEnabledForSource) - { - string sourceEntityName = "SourceEntity", targetEntityName = "TargetEntity"; - - // Create relationship between source and target entities. - EntityRelationship relationship = new( - Cardinality: Cardinality.One, - TargetEntity: targetEntityName, - SourceFields: new string[] { "abc" }, - TargetFields: new string[] { "xyz" }, - LinkingObject: null, - LinkingSourceFields: null, - LinkingTargetFields: null - ); - - // Add another relationship between the same source and target entities. - EntityRelationship duplicateRelationship = new( - Cardinality: Cardinality.Many, - TargetEntity: targetEntityName, - SourceFields: null, - TargetFields: null, - LinkingObject: null, - LinkingSourceFields: null, - LinkingTargetFields: null - ); - - string relationshipName = "relationship", duplicateRelationshipName = "duplicateRelationship"; - Dictionary relationshipMap = new() - { - { relationshipName, relationship }, - { duplicateRelationshipName, duplicateRelationship } - }; - - // Creating source entity with enabled graphQL - Entity sourceEntity = GetSampleEntityUsingSourceAndRelationshipMap( - source: "TEST_SOURCE1", - relationshipMap: relationshipMap, - graphQLDetails: new(Singular: "", Plural: "", Enabled: isGQLEnabledForSource) - ); - - // Creating target entity. - Entity targetEntity = GetSampleEntityUsingSourceAndRelationshipMap( - source: "TEST_SOURCE2", - relationshipMap: null, - graphQLDetails: new("", "", true) - ); - - Dictionary entityMap = new() - { - { sourceEntityName, sourceEntity }, - { targetEntityName, targetEntity } - }; - - RuntimeConfig runtimeConfig = new( - Schema: "UnitTestSchema", - DataSource: new DataSource(DatabaseType: DatabaseType.MSSQL, "", Options: null), - Runtime: new( - Rest: new(), - GraphQL: new(), - Host: new(null, null) - ), - Entities: new(entityMap) - ); - - RuntimeConfigValidator configValidator = InitializeRuntimeConfigValidator(); - Mock _sqlMetadataProvider = new(); - Dictionary mockDictionaryForEntityDatabaseObject = new() - { - { - sourceEntityName, - new DatabaseTable("dbo", "TEST_SOURCE1") - }, - - { - targetEntityName, - new DatabaseTable("dbo", "TEST_SOURCE2") - } - }; - - _sqlMetadataProvider.Setup(x => x.EntityToDatabaseObject).Returns(mockDictionaryForEntityDatabaseObject); - Mock _metadataProviderFactory = new(); - _metadataProviderFactory.Setup(x => x.GetMetadataProvider(It.IsAny())).Returns(_sqlMetadataProvider.Object); - - if (isGQLEnabledForSource) - { - // Assert for expected exception. - DataApiBuilderException ex = Assert.ThrowsException(() => - configValidator.ValidateRelationshipsInConfig(runtimeConfig, _metadataProviderFactory.Object)); - Assert.AreEqual($"Defining multiple relationships: {relationshipName}, {duplicateRelationshipName} between source entity: {sourceEntityName} and target entity: {targetEntityName} is not supported.", ex.Message); - Assert.AreEqual(HttpStatusCode.ServiceUnavailable, ex.StatusCode); - } - else - { - configValidator.ValidateRelationshipsInConfig(runtimeConfig, _metadataProviderFactory.Object); - } - } - /// /// Test method to check that an exception is thrown when LinkingObject was provided /// while either LinkingSourceField or SourceField is null, and either targetFields or LinkingTargetField is null. From f7bc95d9e4052892ccf79f4db7e0d2f53f013b6e Mon Sep 17 00:00:00 2001 From: Shyam Sundar J Date: Tue, 12 Mar 2024 14:39:05 +0530 Subject: [PATCH 3/8] Feature Flag for Nested Mutations: CLI changes (#1983) ## Why make this change? - Closes #1950 - Introduces a feature flag in the config for nested mutation operations. - Feature Flag format: ```json "runtime":{ ... "graphql": { ... "nested-mutations": { "create": { "enabled": true/false } } } } ``` - CLI Option: `--graphql.nested-create.enabled`. This option can be used along with `init` command to enable/disable nested insert operation. - By default, the nested mutation operations will be **disabled**. - Nested Mutation operations are applicable only for MsSQL database type. So, when the option `--graphql.nested-create.enabled` is used along with other database types, it is not honored and nested mutation operations will be disabled. Nested Mutation section will not be written to the config file. In addition, a warning will be logged to let users know that the option is inapplicable. ## What is this change? - `dab.draft.schema.json` - The schema file is updated to contain details about the new fields - `InitOptions` - A new option `--graphql.nested-create.enabled` is introduced for the `init` command. - `NestedCreateOptionsConverter` - Custom converter to read & write the options for nested insert operation from/to the config file respectively. - `NestedMutationOptionsConverter` - Custom converter to read & write the options for nested mutation operations from/to the config file respectively. - `GraphQLRuntimeOptionsConverterFactory` - Updates the logic for reading and writing the graphQL runtime section of the config file. Incorporates logic for reading and writing the nested mutation operation options. - `dab-config.*.json`/`Multidab-config.*.json` - All the reference config files are updated to include the new Nested Mutation options ## How was this tested? - [x] Integration Tests - [x] Unit Tests - [x] Manual Tests ## Sample Commands - **Nested Create Operations are enabled**: `dab init --database-type mssql --connection-string connString --graphql.nested-create.enabled true` ![image](https://github.com/Azure/data-api-builder/assets/11196553/c1821897-1553-46d7-97d2-bf31b7f6178d) - **Nested Create Operations are disabled**: `dab init --database-type mssql --connection-string connString --graphql.nested-create.enabled false` ![image](https://github.com/Azure/data-api-builder/assets/11196553/ea421080-beb8-4f01-a2c9-99916b8b83cc) - **When --graphql.nested-create.graphql option is not used in the init command**: `dab init --database-type mssql --connection-string connString` ![image](https://github.com/Azure/data-api-builder/assets/11196553/d6f1d56c-a553-4dbf-8ad1-e813edc4274d) - **When --graphql.nested-create.graphql option is used with a database type other than MsSQL**: ![image](https://github.com/Azure/data-api-builder/assets/11196553/f9cdda69-f0bd-4e9d-8f65-dd1f0df48402) --- schemas/dab.draft.schema.json | 19 +++ src/Cli.Tests/ConfigGeneratorTests.cs | 2 +- src/Cli.Tests/EndToEndTests.cs | 84 ++++++++++ src/Cli.Tests/InitTests.cs | 83 ++++++++++ ...ationOptions_09cf40a5c545de68.verified.txt | 34 ++++ ...ationOptions_1211ad099a77f7c4.verified.txt | 33 ++++ ...ationOptions_17721ef496526b3e.verified.txt | 38 +++++ ...ationOptions_181195e2fbe991a8.verified.txt | 34 ++++ ...ationOptions_1a73d3cfd329f922.verified.txt | 30 ++++ ...ationOptions_215291b2b7ff2cb4.verified.txt | 30 ++++ ...ationOptions_2be9ac1b7d981cde.verified.txt | 33 ++++ ...ationOptions_35696f184b0ec6f0.verified.txt | 35 ++++ ...ationOptions_384e318d9ed21c9c.verified.txt | 30 ++++ ...ationOptions_388c095980b1b53f.verified.txt | 34 ++++ ...ationOptions_6fb51a691160163b.verified.txt | 30 ++++ ...ationOptions_7c4d5358dc16f63f.verified.txt | 30 ++++ ...ationOptions_8459925dada37738.verified.txt | 30 ++++ ...ationOptions_92ba6ec2f08a3060.verified.txt | 33 ++++ ...ationOptions_9efd9a8a0ff47434.verified.txt | 33 ++++ ...ationOptions_adc642ef89cb6d18.verified.txt | 38 +++++ ...ationOptions_c0ee0e6a86fa0b7e.verified.txt | 30 ++++ ...ationOptions_d1e814ccd5d8b8e8.verified.txt | 30 ++++ ...ationOptions_ef2f00a9e204e114.verified.txt | 30 ++++ src/Cli/Commands/InitOptions.cs | 5 + src/Cli/ConfigGenerator.cs | 33 +++- .../GraphQLRuntimeOptionsConverterFactory.cs | 114 ++++++++++++- .../NestedCreateOptionsConverter.cs | 78 +++++++++ .../NestedMutationOptionsConverter.cs | 88 ++++++++++ .../ObjectModel/GraphQLRuntimeOptions.cs | 5 +- src/Config/ObjectModel/NestedCreateOptions.cs | 21 +++ .../ObjectModel/NestedMutationOptions.cs | 29 ++++ src/Config/RuntimeConfigLoader.cs | 4 +- .../Configuration/ConfigurationTests.cs | 150 ++++++++++++++++++ src/Service.Tests/Multidab-config.MsSql.json | 7 +- src/Service.Tests/TestHelper.cs | 89 +++++++++++ ...untimeConfigLoaderJsonDeserializerTests.cs | 7 +- src/Service.Tests/dab-config.MsSql.json | 7 +- 37 files changed, 1428 insertions(+), 12 deletions(-) create mode 100644 src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_09cf40a5c545de68.verified.txt create mode 100644 src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_1211ad099a77f7c4.verified.txt create mode 100644 src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_17721ef496526b3e.verified.txt create mode 100644 src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_181195e2fbe991a8.verified.txt create mode 100644 src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_1a73d3cfd329f922.verified.txt create mode 100644 src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_215291b2b7ff2cb4.verified.txt create mode 100644 src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_2be9ac1b7d981cde.verified.txt create mode 100644 src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_35696f184b0ec6f0.verified.txt create mode 100644 src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_384e318d9ed21c9c.verified.txt create mode 100644 src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_388c095980b1b53f.verified.txt create mode 100644 src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_6fb51a691160163b.verified.txt create mode 100644 src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_7c4d5358dc16f63f.verified.txt create mode 100644 src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_8459925dada37738.verified.txt create mode 100644 src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_92ba6ec2f08a3060.verified.txt create mode 100644 src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_9efd9a8a0ff47434.verified.txt create mode 100644 src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_adc642ef89cb6d18.verified.txt create mode 100644 src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_c0ee0e6a86fa0b7e.verified.txt create mode 100644 src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_d1e814ccd5d8b8e8.verified.txt create mode 100644 src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_ef2f00a9e204e114.verified.txt create mode 100644 src/Config/Converters/NestedCreateOptionsConverter.cs create mode 100644 src/Config/Converters/NestedMutationOptionsConverter.cs create mode 100644 src/Config/ObjectModel/NestedCreateOptions.cs create mode 100644 src/Config/ObjectModel/NestedMutationOptions.cs diff --git a/schemas/dab.draft.schema.json b/schemas/dab.draft.schema.json index 95172de225..4159308c18 100644 --- a/schemas/dab.draft.schema.json +++ b/schemas/dab.draft.schema.json @@ -175,6 +175,25 @@ "enabled": { "type": "boolean", "description": "Allow enabling/disabling GraphQL requests for all entities." + }, + "nested-mutations": { + "type": "object", + "description": "Configuration properties for nested mutation operations", + "additionalProperties": false, + "properties": { + "create":{ + "type": "object", + "description": "Options for nested create operations", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "description": "Allow enabling/disabling nested create operations for all entities.", + "default": false + } + } + } + } } } }, diff --git a/src/Cli.Tests/ConfigGeneratorTests.cs b/src/Cli.Tests/ConfigGeneratorTests.cs index 52c2019d39..6094189f93 100644 --- a/src/Cli.Tests/ConfigGeneratorTests.cs +++ b/src/Cli.Tests/ConfigGeneratorTests.cs @@ -162,7 +162,7 @@ public void TestSpecialCharactersInConnectionString() ""enabled"": true, ""path"": ""/An_"", ""allow-introspection"": true - }, + }, ""host"": { ""cors"": { ""origins"": [], diff --git a/src/Cli.Tests/EndToEndTests.cs b/src/Cli.Tests/EndToEndTests.cs index 143013e3dc..f15b744d36 100644 --- a/src/Cli.Tests/EndToEndTests.cs +++ b/src/Cli.Tests/EndToEndTests.cs @@ -131,6 +131,90 @@ public void TestInitializingRestAndGraphQLGlobalSettings() Assert.IsTrue(runtimeConfig.Runtime.GraphQL?.Enabled); } + /// + /// Test to validate the usage of --graphql.nested-create.enabled option of the init command for all database types. + /// + /// 1. Behavior for database types other than MsSQL: + /// - Irrespective of whether the --graphql.nested-create.enabled option is used or not, fields related to nested-create will NOT be written to the config file. + /// - As a result, after deserialization of such a config file, the Runtime.GraphQL.NestedMutationOptions is expected to be null. + /// 2. Behavior for MsSQL database type: + /// + /// a. When --graphql.nested-create.enabled option is used + /// - In this case, the fields related to nested mutation and nested create operations will be written to the config file. + /// "nested-mutations": { + /// "create": { + /// "enabled": true/false + /// } + /// } + /// After deserializing such a config file, the Runtime.GraphQL.NestedMutationOptions is expected to be non-null and the value of the "enabled" field is expected to be the same as the value passed in the init command. + /// + /// b. When --graphql.nested-create.enabled option is not used + /// - In this case, fields related to nested mutation and nested create operations will NOT be written to the config file. + /// - As a result, after deserialization of such a config file, the Runtime.GraphQL.NestedMutationOptions is expected to be null. + /// + /// + /// Value interpreted by the CLI for '--graphql.nested-create.enabled' option of the init command. + /// When not used, CLI interprets the value for the option as CliBool.None + /// When used with true/false, CLI interprets the value as CliBool.True/CliBool.False respectively. + /// + /// Expected value for the nested create enabled flag in the config file. + [DataTestMethod] + [DataRow(CliBool.True, "mssql", DatabaseType.MSSQL, DisplayName = "Init command with '--graphql.nested-create.enabled true' for MsSql database type")] + [DataRow(CliBool.False, "mssql", DatabaseType.MSSQL, DisplayName = "Init command with '--graphql.nested-create.enabled false' for MsSql database type")] + [DataRow(CliBool.None, "mssql", DatabaseType.MSSQL, DisplayName = "Init command without '--graphql.nested-create.enabled' option for MsSql database type")] + [DataRow(CliBool.True, "mysql", DatabaseType.MySQL, DisplayName = "Init command with '--graphql.nested-create.enabled true' for MySql database type")] + [DataRow(CliBool.False, "mysql", DatabaseType.MySQL, DisplayName = "Init command with '--graphql.nested-create.enabled false' for MySql database type")] + [DataRow(CliBool.None, "mysql", DatabaseType.MySQL, DisplayName = "Init command without '--graphql.nested-create.enabled' option for MySql database type")] + [DataRow(CliBool.True, "postgresql", DatabaseType.PostgreSQL, DisplayName = "Init command with '--graphql.nested-create.enabled true' for PostgreSql database type")] + [DataRow(CliBool.False, "postgresql", DatabaseType.PostgreSQL, DisplayName = "Init command with '--graphql.nested-create.enabled false' for PostgreSql database type")] + [DataRow(CliBool.None, "postgresql", DatabaseType.PostgreSQL, DisplayName = "Init command without '--graphql.nested-create.enabled' option for PostgreSql database type")] + [DataRow(CliBool.True, "dwsql", DatabaseType.DWSQL, DisplayName = "Init command with '--graphql.nested-create.enabled true' for dwsql database type")] + [DataRow(CliBool.False, "dwsql", DatabaseType.DWSQL, DisplayName = "Init command with '--graphql.nested-create.enabled false' for dwsql database type")] + [DataRow(CliBool.None, "dwsql", DatabaseType.DWSQL, DisplayName = "Init command without '--graphql.nested-create.enabled' option for dwsql database type")] + [DataRow(CliBool.True, "cosmosdb_nosql", DatabaseType.CosmosDB_NoSQL, DisplayName = "Init command with '--graphql.nested-create.enabled true' for cosmosdb_nosql database type")] + [DataRow(CliBool.False, "cosmosdb_nosql", DatabaseType.CosmosDB_NoSQL, DisplayName = "Init command with '--graphql.nested-create.enabled false' for cosmosdb_nosql database type")] + [DataRow(CliBool.None, "cosmosdb_nosql", DatabaseType.CosmosDB_NoSQL, DisplayName = "Init command without '--graphql.nested-create.enabled' option for cosmosdb_nosql database type")] + public void TestEnablingNestedCreateOperation(CliBool isNestedCreateEnabled, string dbType, DatabaseType expectedDbType) + { + List args = new() { "init", "-c", TEST_RUNTIME_CONFIG_FILE, "--connection-string", SAMPLE_TEST_CONN_STRING, "--database-type", dbType }; + + if (string.Equals("cosmosdb_nosql", dbType, StringComparison.OrdinalIgnoreCase)) + { + List cosmosNoSqlArgs = new() { "--cosmosdb_nosql-database", + "graphqldb", "--cosmosdb_nosql-container", "planet", "--graphql-schema", TEST_SCHEMA_FILE}; + args.AddRange(cosmosNoSqlArgs); + } + + if (isNestedCreateEnabled is not CliBool.None) + { + args.Add("--graphql.nested-create.enabled"); + args.Add(isNestedCreateEnabled.ToString()!); + } + + Program.Execute(args.ToArray(), _cliLogger!, _fileSystem!, _runtimeConfigLoader!); + + Assert.IsTrue(_runtimeConfigLoader!.TryLoadConfig( + TEST_RUNTIME_CONFIG_FILE, + out RuntimeConfig? runtimeConfig, + replaceEnvVar: true)); + + Assert.IsNotNull(runtimeConfig); + Assert.AreEqual(expectedDbType, runtimeConfig.DataSource.DatabaseType); + Assert.IsNotNull(runtimeConfig.Runtime); + Assert.IsNotNull(runtimeConfig.Runtime.GraphQL); + if (runtimeConfig.DataSource.DatabaseType is DatabaseType.MSSQL && isNestedCreateEnabled is not CliBool.None) + { + Assert.IsNotNull(runtimeConfig.Runtime.GraphQL.NestedMutationOptions); + Assert.IsNotNull(runtimeConfig.Runtime.GraphQL.NestedMutationOptions.NestedCreateOptions); + bool expectedValueForNestedCreateEnabled = isNestedCreateEnabled == CliBool.True; + Assert.AreEqual(expectedValueForNestedCreateEnabled, runtimeConfig.Runtime.GraphQL.NestedMutationOptions.NestedCreateOptions.Enabled); + } + else + { + Assert.IsNull(runtimeConfig.Runtime.GraphQL.NestedMutationOptions, message: "NestedMutationOptions is expected to be null because a) DB type is not MsSQL or b) Either --graphql.nested-create.enabled option was not used or no value was provided."); + } + } + /// /// Test to verify adding a new Entity. /// diff --git a/src/Cli.Tests/InitTests.cs b/src/Cli.Tests/InitTests.cs index 31cff258a7..bfd0a7a19c 100644 --- a/src/Cli.Tests/InitTests.cs +++ b/src/Cli.Tests/InitTests.cs @@ -409,6 +409,89 @@ public Task GraphQLPathWithoutStartingSlashWillHaveItAdded() return ExecuteVerifyTest(options); } + /// + /// Test to validate the contents of the config file generated when init command is used with --graphql.nested-create.enabled flag option for different database types. + /// + /// 1. For database types other than MsSQL: + /// - Irrespective of whether the --graphql.nested-create.enabled option is used or not, fields related to nested-create will NOT be written to the config file. + /// + /// 2. For MsSQL database type: + /// a. When --graphql.nested-create.enabled option is used + /// - In this case, the fields related to nested mutation and nested create operations will be written to the config file. + /// "nested-mutations": { + /// "create": { + /// "enabled": true/false + /// } + /// } + /// + /// b. When --graphql.nested-create.enabled option is not used + /// - In this case, fields related to nested mutation and nested create operations will NOT be written to the config file. + /// + /// + [DataTestMethod] + [DataRow(DatabaseType.MSSQL, CliBool.True, DisplayName = "Init command with '--graphql.nested-create.enabled true' for MsSQL database type")] + [DataRow(DatabaseType.MSSQL, CliBool.False, DisplayName = "Init command with '--graphql.nested-create.enabled false' for MsSQL database type")] + [DataRow(DatabaseType.MSSQL, CliBool.None, DisplayName = "Init command without '--graphql.nested-create.enabled' option for MsSQL database type")] + [DataRow(DatabaseType.PostgreSQL, CliBool.True, DisplayName = "Init command with '--graphql.nested-create.enabled true' for PostgreSQL database type")] + [DataRow(DatabaseType.PostgreSQL, CliBool.False, DisplayName = "Init command with '--graphql.nested-create.enabled false' for PostgreSQL database type")] + [DataRow(DatabaseType.PostgreSQL, CliBool.None, DisplayName = "Init command without '--graphql.nested-create.enabled' option for PostgreSQL database type")] + [DataRow(DatabaseType.MySQL, CliBool.True, DisplayName = "Init command with '--graphql.nested-create.enabled true' for MySQL database type")] + [DataRow(DatabaseType.MySQL, CliBool.False, DisplayName = "Init command with '--graphql.nested-create.enabled false' for MySQL database type")] + [DataRow(DatabaseType.MySQL, CliBool.None, DisplayName = "Init command without '--graphql.nested-create.enabled' option for MySQL database type")] + [DataRow(DatabaseType.CosmosDB_NoSQL, CliBool.True, DisplayName = "Init command with '--graphql.nested-create.enabled true' for CosmosDB_NoSQL database type")] + [DataRow(DatabaseType.CosmosDB_NoSQL, CliBool.False, DisplayName = "Init command with '--graphql.nested-create.enabled false' for CosmosDB_NoSQL database type")] + [DataRow(DatabaseType.CosmosDB_NoSQL, CliBool.None, DisplayName = "Init command without '--graphql.nested-create.enabled' option for CosmosDB_NoSQL database type")] + [DataRow(DatabaseType.CosmosDB_PostgreSQL, CliBool.True, DisplayName = "Init command with '--graphql.nested-create.enabled true' for CosmosDB_PostgreSQL database type")] + [DataRow(DatabaseType.CosmosDB_PostgreSQL, CliBool.False, DisplayName = "Init command with '--graphql.nested-create.enabled false' for CosmosDB_PostgreSQL database type")] + [DataRow(DatabaseType.CosmosDB_PostgreSQL, CliBool.None, DisplayName = "Init command without '--graphql.nested-create.enabled' option for CosmosDB_PostgreSQL database type")] + [DataRow(DatabaseType.DWSQL, CliBool.True, DisplayName = "Init command with '--graphql.nested-create.enabled true' for DWSQL database type")] + [DataRow(DatabaseType.DWSQL, CliBool.False, DisplayName = "Init command with '--graphql.nested-create.enabled false' for DWSQL database type")] + [DataRow(DatabaseType.DWSQL, CliBool.None, DisplayName = "Init command without '--graphql.nested-create.enabled' option for DWSQL database type")] + public Task VerifyCorrectConfigGenerationWithNestedMutationOptions(DatabaseType databaseType, CliBool isNestedCreateEnabled) + { + InitOptions options; + + if (databaseType is DatabaseType.CosmosDB_NoSQL) + { + // A schema file is added since its mandatory for CosmosDB_NoSQL + ((MockFileSystem)_fileSystem!).AddFile(TEST_SCHEMA_FILE, new MockFileData("")); + + options = new( + databaseType: databaseType, + connectionString: "testconnectionstring", + cosmosNoSqlDatabase: "testdb", + cosmosNoSqlContainer: "testcontainer", + graphQLSchemaPath: TEST_SCHEMA_FILE, + setSessionContext: true, + hostMode: HostMode.Development, + corsOrigin: new List() { "http://localhost:3000", "http://nolocalhost:80" }, + authenticationProvider: EasyAuthType.StaticWebApps.ToString(), + restPath: "rest-api", + config: TEST_RUNTIME_CONFIG_FILE, + nestedCreateOperationEnabled: isNestedCreateEnabled); + } + else + { + options = new( + databaseType: databaseType, + connectionString: "testconnectionstring", + cosmosNoSqlDatabase: null, + cosmosNoSqlContainer: null, + graphQLSchemaPath: null, + setSessionContext: true, + hostMode: HostMode.Development, + corsOrigin: new List() { "http://localhost:3000", "http://nolocalhost:80" }, + authenticationProvider: EasyAuthType.StaticWebApps.ToString(), + restPath: "rest-api", + config: TEST_RUNTIME_CONFIG_FILE, + nestedCreateOperationEnabled: isNestedCreateEnabled); + } + + VerifySettings verifySettings = new(); + verifySettings.UseHashedParameters(databaseType, isNestedCreateEnabled); + return ExecuteVerifyTest(options, verifySettings); + } + private Task ExecuteVerifyTest(InitOptions options, VerifySettings? settings = null) { Assert.IsTrue(TryCreateRuntimeConfig(options, _runtimeConfigLoader!, _fileSystem!, out RuntimeConfig? runtimeConfig)); diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_09cf40a5c545de68.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_09cf40a5c545de68.verified.txt new file mode 100644 index 0000000000..9740a85a77 --- /dev/null +++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_09cf40a5c545de68.verified.txt @@ -0,0 +1,34 @@ +{ + DataSource: { + Options: { + container: testcontainer, + database: testdb, + schema: test-schema.gql + } + }, + Runtime: { + Rest: { + Enabled: false, + Path: /api, + RequestBodyStrict: true + }, + GraphQL: { + Enabled: true, + Path: /graphql, + AllowIntrospection: true + }, + Host: { + Cors: { + Origins: [ + http://localhost:3000, + http://nolocalhost:80 + ], + AllowCredentials: false + }, + Authentication: { + Provider: StaticWebApps + } + } + }, + Entities: [] +} \ No newline at end of file diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_1211ad099a77f7c4.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_1211ad099a77f7c4.verified.txt new file mode 100644 index 0000000000..da7937d1d9 --- /dev/null +++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_1211ad099a77f7c4.verified.txt @@ -0,0 +1,33 @@ +{ + DataSource: { + DatabaseType: MSSQL, + Options: { + set-session-context: true + } + }, + Runtime: { + Rest: { + Enabled: true, + Path: /rest-api, + RequestBodyStrict: true + }, + GraphQL: { + Enabled: true, + Path: /graphql, + AllowIntrospection: true + }, + Host: { + Cors: { + Origins: [ + http://localhost:3000, + http://nolocalhost:80 + ], + AllowCredentials: false + }, + Authentication: { + Provider: StaticWebApps + } + } + }, + Entities: [] +} \ No newline at end of file diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_17721ef496526b3e.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_17721ef496526b3e.verified.txt new file mode 100644 index 0000000000..078169b766 --- /dev/null +++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_17721ef496526b3e.verified.txt @@ -0,0 +1,38 @@ +{ + DataSource: { + DatabaseType: MSSQL, + Options: { + set-session-context: true + } + }, + Runtime: { + Rest: { + Enabled: true, + Path: /rest-api, + RequestBodyStrict: true + }, + GraphQL: { + Enabled: true, + Path: /graphql, + AllowIntrospection: true, + NestedMutationOptions: { + NestedCreateOptions: { + Enabled: true + } + } + }, + Host: { + Cors: { + Origins: [ + http://localhost:3000, + http://nolocalhost:80 + ], + AllowCredentials: false + }, + Authentication: { + Provider: StaticWebApps + } + } + }, + Entities: [] +} \ No newline at end of file diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_181195e2fbe991a8.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_181195e2fbe991a8.verified.txt new file mode 100644 index 0000000000..9740a85a77 --- /dev/null +++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_181195e2fbe991a8.verified.txt @@ -0,0 +1,34 @@ +{ + DataSource: { + Options: { + container: testcontainer, + database: testdb, + schema: test-schema.gql + } + }, + Runtime: { + Rest: { + Enabled: false, + Path: /api, + RequestBodyStrict: true + }, + GraphQL: { + Enabled: true, + Path: /graphql, + AllowIntrospection: true + }, + Host: { + Cors: { + Origins: [ + http://localhost:3000, + http://nolocalhost:80 + ], + AllowCredentials: false + }, + Authentication: { + Provider: StaticWebApps + } + } + }, + Entities: [] +} \ No newline at end of file diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_1a73d3cfd329f922.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_1a73d3cfd329f922.verified.txt new file mode 100644 index 0000000000..a43e68277c --- /dev/null +++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_1a73d3cfd329f922.verified.txt @@ -0,0 +1,30 @@ +{ + DataSource: { + DatabaseType: MySQL + }, + Runtime: { + Rest: { + Enabled: true, + Path: /rest-api, + RequestBodyStrict: true + }, + GraphQL: { + Enabled: true, + Path: /graphql, + AllowIntrospection: true + }, + Host: { + Cors: { + Origins: [ + http://localhost:3000, + http://nolocalhost:80 + ], + AllowCredentials: false + }, + Authentication: { + Provider: StaticWebApps + } + } + }, + Entities: [] +} \ No newline at end of file diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_215291b2b7ff2cb4.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_215291b2b7ff2cb4.verified.txt new file mode 100644 index 0000000000..3285438ab7 --- /dev/null +++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_215291b2b7ff2cb4.verified.txt @@ -0,0 +1,30 @@ +{ + DataSource: { + DatabaseType: PostgreSQL + }, + Runtime: { + Rest: { + Enabled: true, + Path: /rest-api, + RequestBodyStrict: true + }, + GraphQL: { + Enabled: true, + Path: /graphql, + AllowIntrospection: true + }, + Host: { + Cors: { + Origins: [ + http://localhost:3000, + http://nolocalhost:80 + ], + AllowCredentials: false + }, + Authentication: { + Provider: StaticWebApps + } + } + }, + Entities: [] +} \ No newline at end of file diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_2be9ac1b7d981cde.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_2be9ac1b7d981cde.verified.txt new file mode 100644 index 0000000000..cbaaa45754 --- /dev/null +++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_2be9ac1b7d981cde.verified.txt @@ -0,0 +1,33 @@ +{ + DataSource: { + DatabaseType: DWSQL, + Options: { + set-session-context: true + } + }, + Runtime: { + Rest: { + Enabled: true, + Path: /rest-api, + RequestBodyStrict: true + }, + GraphQL: { + Enabled: true, + Path: /graphql, + AllowIntrospection: true + }, + Host: { + Cors: { + Origins: [ + http://localhost:3000, + http://nolocalhost:80 + ], + AllowCredentials: false + }, + Authentication: { + Provider: StaticWebApps + } + } + }, + Entities: [] +} \ No newline at end of file diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_35696f184b0ec6f0.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_35696f184b0ec6f0.verified.txt new file mode 100644 index 0000000000..794686467c --- /dev/null +++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_35696f184b0ec6f0.verified.txt @@ -0,0 +1,35 @@ +{ + DataSource: { + DatabaseType: CosmosDB_PostgreSQL + }, + Runtime: { + Rest: { + Enabled: true, + Path: /rest-api, + RequestBodyStrict: true + }, + GraphQL: { + Enabled: true, + Path: /graphql, + AllowIntrospection: true, + NestedMutationOptions: { + NestedCreateOptions: { + Enabled: false + } + } + }, + Host: { + Cors: { + Origins: [ + http://localhost:3000, + http://nolocalhost:80 + ], + AllowCredentials: false + }, + Authentication: { + Provider: StaticWebApps + } + } + }, + Entities: [] +} \ No newline at end of file diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_384e318d9ed21c9c.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_384e318d9ed21c9c.verified.txt new file mode 100644 index 0000000000..a43e68277c --- /dev/null +++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_384e318d9ed21c9c.verified.txt @@ -0,0 +1,30 @@ +{ + DataSource: { + DatabaseType: MySQL + }, + Runtime: { + Rest: { + Enabled: true, + Path: /rest-api, + RequestBodyStrict: true + }, + GraphQL: { + Enabled: true, + Path: /graphql, + AllowIntrospection: true + }, + Host: { + Cors: { + Origins: [ + http://localhost:3000, + http://nolocalhost:80 + ], + AllowCredentials: false + }, + Authentication: { + Provider: StaticWebApps + } + } + }, + Entities: [] +} \ No newline at end of file diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_388c095980b1b53f.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_388c095980b1b53f.verified.txt new file mode 100644 index 0000000000..9740a85a77 --- /dev/null +++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_388c095980b1b53f.verified.txt @@ -0,0 +1,34 @@ +{ + DataSource: { + Options: { + container: testcontainer, + database: testdb, + schema: test-schema.gql + } + }, + Runtime: { + Rest: { + Enabled: false, + Path: /api, + RequestBodyStrict: true + }, + GraphQL: { + Enabled: true, + Path: /graphql, + AllowIntrospection: true + }, + Host: { + Cors: { + Origins: [ + http://localhost:3000, + http://nolocalhost:80 + ], + AllowCredentials: false + }, + Authentication: { + Provider: StaticWebApps + } + } + }, + Entities: [] +} \ No newline at end of file diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_6fb51a691160163b.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_6fb51a691160163b.verified.txt new file mode 100644 index 0000000000..a43e68277c --- /dev/null +++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_6fb51a691160163b.verified.txt @@ -0,0 +1,30 @@ +{ + DataSource: { + DatabaseType: MySQL + }, + Runtime: { + Rest: { + Enabled: true, + Path: /rest-api, + RequestBodyStrict: true + }, + GraphQL: { + Enabled: true, + Path: /graphql, + AllowIntrospection: true + }, + Host: { + Cors: { + Origins: [ + http://localhost:3000, + http://nolocalhost:80 + ], + AllowCredentials: false + }, + Authentication: { + Provider: StaticWebApps + } + } + }, + Entities: [] +} \ No newline at end of file diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_7c4d5358dc16f63f.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_7c4d5358dc16f63f.verified.txt new file mode 100644 index 0000000000..3285438ab7 --- /dev/null +++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_7c4d5358dc16f63f.verified.txt @@ -0,0 +1,30 @@ +{ + DataSource: { + DatabaseType: PostgreSQL + }, + Runtime: { + Rest: { + Enabled: true, + Path: /rest-api, + RequestBodyStrict: true + }, + GraphQL: { + Enabled: true, + Path: /graphql, + AllowIntrospection: true + }, + Host: { + Cors: { + Origins: [ + http://localhost:3000, + http://nolocalhost:80 + ], + AllowCredentials: false + }, + Authentication: { + Provider: StaticWebApps + } + } + }, + Entities: [] +} \ No newline at end of file diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_8459925dada37738.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_8459925dada37738.verified.txt new file mode 100644 index 0000000000..673c21dae4 --- /dev/null +++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_8459925dada37738.verified.txt @@ -0,0 +1,30 @@ +{ + DataSource: { + DatabaseType: CosmosDB_PostgreSQL + }, + Runtime: { + Rest: { + Enabled: true, + Path: /rest-api, + RequestBodyStrict: true + }, + GraphQL: { + Enabled: true, + Path: /graphql, + AllowIntrospection: true + }, + Host: { + Cors: { + Origins: [ + http://localhost:3000, + http://nolocalhost:80 + ], + AllowCredentials: false + }, + Authentication: { + Provider: StaticWebApps + } + } + }, + Entities: [] +} \ No newline at end of file diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_92ba6ec2f08a3060.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_92ba6ec2f08a3060.verified.txt new file mode 100644 index 0000000000..cbaaa45754 --- /dev/null +++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_92ba6ec2f08a3060.verified.txt @@ -0,0 +1,33 @@ +{ + DataSource: { + DatabaseType: DWSQL, + Options: { + set-session-context: true + } + }, + Runtime: { + Rest: { + Enabled: true, + Path: /rest-api, + RequestBodyStrict: true + }, + GraphQL: { + Enabled: true, + Path: /graphql, + AllowIntrospection: true + }, + Host: { + Cors: { + Origins: [ + http://localhost:3000, + http://nolocalhost:80 + ], + AllowCredentials: false + }, + Authentication: { + Provider: StaticWebApps + } + } + }, + Entities: [] +} \ No newline at end of file diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_9efd9a8a0ff47434.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_9efd9a8a0ff47434.verified.txt new file mode 100644 index 0000000000..cbaaa45754 --- /dev/null +++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_9efd9a8a0ff47434.verified.txt @@ -0,0 +1,33 @@ +{ + DataSource: { + DatabaseType: DWSQL, + Options: { + set-session-context: true + } + }, + Runtime: { + Rest: { + Enabled: true, + Path: /rest-api, + RequestBodyStrict: true + }, + GraphQL: { + Enabled: true, + Path: /graphql, + AllowIntrospection: true + }, + Host: { + Cors: { + Origins: [ + http://localhost:3000, + http://nolocalhost:80 + ], + AllowCredentials: false + }, + Authentication: { + Provider: StaticWebApps + } + } + }, + Entities: [] +} \ No newline at end of file diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_adc642ef89cb6d18.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_adc642ef89cb6d18.verified.txt new file mode 100644 index 0000000000..65cf6b8748 --- /dev/null +++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_adc642ef89cb6d18.verified.txt @@ -0,0 +1,38 @@ +{ + DataSource: { + DatabaseType: MSSQL, + Options: { + set-session-context: true + } + }, + Runtime: { + Rest: { + Enabled: true, + Path: /rest-api, + RequestBodyStrict: true + }, + GraphQL: { + Enabled: true, + Path: /graphql, + AllowIntrospection: true, + NestedMutationOptions: { + NestedCreateOptions: { + Enabled: false + } + } + }, + Host: { + Cors: { + Origins: [ + http://localhost:3000, + http://nolocalhost:80 + ], + AllowCredentials: false + }, + Authentication: { + Provider: StaticWebApps + } + } + }, + Entities: [] +} \ No newline at end of file diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_c0ee0e6a86fa0b7e.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_c0ee0e6a86fa0b7e.verified.txt new file mode 100644 index 0000000000..3285438ab7 --- /dev/null +++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_c0ee0e6a86fa0b7e.verified.txt @@ -0,0 +1,30 @@ +{ + DataSource: { + DatabaseType: PostgreSQL + }, + Runtime: { + Rest: { + Enabled: true, + Path: /rest-api, + RequestBodyStrict: true + }, + GraphQL: { + Enabled: true, + Path: /graphql, + AllowIntrospection: true + }, + Host: { + Cors: { + Origins: [ + http://localhost:3000, + http://nolocalhost:80 + ], + AllowCredentials: false + }, + Authentication: { + Provider: StaticWebApps + } + } + }, + Entities: [] +} \ No newline at end of file diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_d1e814ccd5d8b8e8.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_d1e814ccd5d8b8e8.verified.txt new file mode 100644 index 0000000000..673c21dae4 --- /dev/null +++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_d1e814ccd5d8b8e8.verified.txt @@ -0,0 +1,30 @@ +{ + DataSource: { + DatabaseType: CosmosDB_PostgreSQL + }, + Runtime: { + Rest: { + Enabled: true, + Path: /rest-api, + RequestBodyStrict: true + }, + GraphQL: { + Enabled: true, + Path: /graphql, + AllowIntrospection: true + }, + Host: { + Cors: { + Origins: [ + http://localhost:3000, + http://nolocalhost:80 + ], + AllowCredentials: false + }, + Authentication: { + Provider: StaticWebApps + } + } + }, + Entities: [] +} \ No newline at end of file diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_ef2f00a9e204e114.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_ef2f00a9e204e114.verified.txt new file mode 100644 index 0000000000..673c21dae4 --- /dev/null +++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_ef2f00a9e204e114.verified.txt @@ -0,0 +1,30 @@ +{ + DataSource: { + DatabaseType: CosmosDB_PostgreSQL + }, + Runtime: { + Rest: { + Enabled: true, + Path: /rest-api, + RequestBodyStrict: true + }, + GraphQL: { + Enabled: true, + Path: /graphql, + AllowIntrospection: true + }, + Host: { + Cors: { + Origins: [ + http://localhost:3000, + http://nolocalhost:80 + ], + AllowCredentials: false + }, + Authentication: { + Provider: StaticWebApps + } + } + }, + Entities: [] +} \ No newline at end of file diff --git a/src/Cli/Commands/InitOptions.cs b/src/Cli/Commands/InitOptions.cs index 1d7ec4bf59..a3d49d6bb1 100644 --- a/src/Cli/Commands/InitOptions.cs +++ b/src/Cli/Commands/InitOptions.cs @@ -37,6 +37,7 @@ public InitOptions( CliBool restEnabled = CliBool.None, CliBool graphqlEnabled = CliBool.None, CliBool restRequestBodyStrict = CliBool.None, + CliBool nestedCreateOperationEnabled = CliBool.None, string? config = null) : base(config) { @@ -59,6 +60,7 @@ public InitOptions( RestEnabled = restEnabled; GraphQLEnabled = graphqlEnabled; RestRequestBodyStrict = restRequestBodyStrict; + NestedCreateOperationEnabled = nestedCreateOperationEnabled; } [Option("database-type", Required = true, HelpText = "Type of database to connect. Supported values: mssql, cosmosdb_nosql, cosmosdb_postgresql, mysql, postgresql, dwsql")] @@ -120,6 +122,9 @@ public InitOptions( [Option("rest.request-body-strict", Required = false, HelpText = "(Default: true) Allow extraneous fields in the request body for REST.")] public CliBool RestRequestBodyStrict { get; } + [Option("graphql.nested-create.enabled", Required = false, HelpText = "(Default: false) Enables nested create operation for GraphQL. Supported values: true, false.")] + public CliBool NestedCreateOperationEnabled { get; } + public void Handler(ILogger logger, FileSystemRuntimeConfigLoader loader, IFileSystem fileSystem) { logger.LogInformation("{productName} {version}", PRODUCT_NAME, ProductInfo.GetProductVersion()); diff --git a/src/Cli/ConfigGenerator.cs b/src/Cli/ConfigGenerator.cs index a63b6a19ed..4b819bf28d 100644 --- a/src/Cli/ConfigGenerator.cs +++ b/src/Cli/ConfigGenerator.cs @@ -113,6 +113,27 @@ public static bool TryCreateRuntimeConfig(InitOptions options, FileSystemRuntime return false; } + bool isNestedCreateEnabledForGraphQL; + + // Nested mutation operations are applicable only for MSSQL database. When the option --graphql.nested-create.enabled is specified for other database types, + // a warning is logged. + // When nested mutation operations are extended for other database types, this option should be honored. + // Tracked by issue #2001: https://github.com/Azure/data-api-builder/issues/2001. + if (dbType is not DatabaseType.MSSQL && options.NestedCreateOperationEnabled is not CliBool.None) + { + _logger.LogWarning($"The option --graphql.nested-create.enabled is not supported for the {dbType.ToString()} database type and will not be honored."); + } + + NestedMutationOptions? nestedMutationOptions = null; + + // Nested mutation operations are applicable only for MSSQL database. When the option --graphql.nested-create.enabled is specified for other database types, + // it is not honored. + if (dbType is DatabaseType.MSSQL && options.NestedCreateOperationEnabled is not CliBool.None) + { + isNestedCreateEnabledForGraphQL = IsNestedCreateOperationEnabled(options.NestedCreateOperationEnabled); + nestedMutationOptions = new(nestedCreateOptions: new NestedCreateOptions(enabled: isNestedCreateEnabledForGraphQL)); + } + switch (dbType) { case DatabaseType.CosmosDB_NoSQL: @@ -232,7 +253,7 @@ public static bool TryCreateRuntimeConfig(InitOptions options, FileSystemRuntime DataSource: dataSource, Runtime: new( Rest: new(restEnabled, restPath ?? RestRuntimeOptions.DEFAULT_PATH, options.RestRequestBodyStrict is CliBool.False ? false : true), - GraphQL: new(graphQLEnabled, graphQLPath), + GraphQL: new(Enabled: graphQLEnabled, Path: graphQLPath, NestedMutationOptions: nestedMutationOptions), Host: new( Cors: new(options.CorsOrigin?.ToArray() ?? Array.Empty()), Authentication: new( @@ -285,6 +306,16 @@ private static bool TryDetermineIfApiIsEnabled(bool apiDisabledOptionValue, CliB return true; } + /// + /// Helper method to determine if the nested create operation is enabled or not based on the inputs from dab init command. + /// + /// Input value for --graphql.nested-create.enabled option of the init command + /// True/False + private static bool IsNestedCreateOperationEnabled(CliBool nestedCreateEnabledOptionValue) + { + return nestedCreateEnabledOptionValue is CliBool.True; + } + /// /// This method will add a new Entity with the given REST and GraphQL endpoints, source, and permissions. /// It also supports fields that needs to be included or excluded for a given role and operation. diff --git a/src/Config/Converters/GraphQLRuntimeOptionsConverterFactory.cs b/src/Config/Converters/GraphQLRuntimeOptionsConverterFactory.cs index 7bca48106a..0101cbba87 100644 --- a/src/Config/Converters/GraphQLRuntimeOptionsConverterFactory.cs +++ b/src/Config/Converters/GraphQLRuntimeOptionsConverterFactory.cs @@ -9,6 +9,10 @@ namespace Azure.DataApiBuilder.Config.Converters; internal class GraphQLRuntimeOptionsConverterFactory : JsonConverterFactory { + // Determines whether to replace environment variable with its + // value or not while deserializing. + private bool _replaceEnvVar; + /// public override bool CanConvert(Type typeToConvert) { @@ -18,11 +22,27 @@ public override bool CanConvert(Type typeToConvert) /// public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) { - return new GraphQLRuntimeOptionsConverter(); + return new GraphQLRuntimeOptionsConverter(_replaceEnvVar); + } + + internal GraphQLRuntimeOptionsConverterFactory(bool replaceEnvVar) + { + _replaceEnvVar = replaceEnvVar; } private class GraphQLRuntimeOptionsConverter : JsonConverter { + // Determines whether to replace environment variable with its + // value or not while deserializing. + private bool _replaceEnvVar; + + /// Whether to replace environment variable with its + /// value or not while deserializing. + internal GraphQLRuntimeOptionsConverter(bool replaceEnvVar) + { + _replaceEnvVar = replaceEnvVar; + } + public override GraphQLRuntimeOptions? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { if (reader.TokenType == JsonTokenType.True || reader.TokenType == JsonTokenType.Null) @@ -35,11 +55,85 @@ private class GraphQLRuntimeOptionsConverter : JsonConverter c is GraphQLRuntimeOptionsConverterFactory)); + if (reader.TokenType == JsonTokenType.StartObject) + { + // Initialize with Nested Mutation operations disabled by default + GraphQLRuntimeOptions graphQLRuntimeOptions = new(); + NestedMutationOptionsConverter nestedMutationOptionsConverter = options.GetConverter(typeof(NestedMutationOptions)) as NestedMutationOptionsConverter ?? + throw new JsonException("Failed to get nested mutation options converter"); + + while (reader.Read()) + { + + if (reader.TokenType == JsonTokenType.EndObject) + { + break; + } + + string? propertyName = reader.GetString(); + + if (propertyName is null) + { + throw new JsonException("Invalid property : null"); + } + + reader.Read(); + switch (propertyName) + { + case "enabled": + if (reader.TokenType is JsonTokenType.True || reader.TokenType is JsonTokenType.False) + { + graphQLRuntimeOptions = graphQLRuntimeOptions with { Enabled = reader.GetBoolean() }; + } + else + { + throw new JsonException($"Unsupported value entered for the property 'enabled': {reader.TokenType}"); + } + + break; - return JsonSerializer.Deserialize(ref reader, innerOptions); + case "allow-introspection": + if (reader.TokenType is JsonTokenType.True || reader.TokenType is JsonTokenType.False) + { + graphQLRuntimeOptions = graphQLRuntimeOptions with { AllowIntrospection = reader.GetBoolean() }; + } + else + { + throw new JsonException($"Unexpected type of value entered for allow-introspection: {reader.TokenType}"); + } + + break; + case "path": + if (reader.TokenType is JsonTokenType.String) + { + string? path = reader.DeserializeString(_replaceEnvVar); + if (path is null) + { + path = "/graphql"; + } + + graphQLRuntimeOptions = graphQLRuntimeOptions with { Path = path }; + } + else + { + throw new JsonException($"Unexpected type of value entered for path: {reader.TokenType}"); + } + + break; + + case "nested-mutations": + graphQLRuntimeOptions = graphQLRuntimeOptions with { NestedMutationOptions = nestedMutationOptionsConverter.Read(ref reader, typeToConvert, options) }; + break; + + default: + throw new JsonException($"Unexpected property {propertyName}"); + } + } + + return graphQLRuntimeOptions; + } + + throw new JsonException("Failed to read the GraphQL Runtime Options"); } public override void Write(Utf8JsonWriter writer, GraphQLRuntimeOptions value, JsonSerializerOptions options) @@ -48,6 +142,16 @@ public override void Write(Utf8JsonWriter writer, GraphQLRuntimeOptions value, J writer.WriteBoolean("enabled", value.Enabled); writer.WriteString("path", value.Path); writer.WriteBoolean("allow-introspection", value.AllowIntrospection); + + if (value.NestedMutationOptions is not null) + { + + NestedMutationOptionsConverter nestedMutationOptionsConverter = options.GetConverter(typeof(NestedMutationOptions)) as NestedMutationOptionsConverter ?? + throw new JsonException("Failed to get nested mutation options converter"); + + nestedMutationOptionsConverter.Write(writer, value.NestedMutationOptions, options); + } + writer.WriteEndObject(); } } diff --git a/src/Config/Converters/NestedCreateOptionsConverter.cs b/src/Config/Converters/NestedCreateOptionsConverter.cs new file mode 100644 index 0000000000..7e495ef303 --- /dev/null +++ b/src/Config/Converters/NestedCreateOptionsConverter.cs @@ -0,0 +1,78 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Text.Json.Serialization; +using Azure.DataApiBuilder.Config.ObjectModel; + +namespace Azure.DataApiBuilder.Config.Converters +{ + /// + /// Converter for the nested create operation options. + /// + internal class NestedCreateOptionsConverter : JsonConverter + { + /// + public override NestedCreateOptions? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + { + return null; + } + + if (reader.TokenType is JsonTokenType.StartObject) + { + NestedCreateOptions? nestedCreateOptions = null; + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + { + break; + } + + string? propertyName = reader.GetString(); + + if (propertyName is null) + { + throw new JsonException("Invalid property : null"); + } + + switch (propertyName) + { + case "enabled": + reader.Read(); + if (reader.TokenType is JsonTokenType.True || reader.TokenType is JsonTokenType.False) + { + nestedCreateOptions = new(reader.GetBoolean()); + } + + break; + default: + throw new JsonException($"Unexpected property {propertyName}"); + } + } + + return nestedCreateOptions; + } + + throw new JsonException("Failed to read the GraphQL Nested Create options"); + } + + /// + public override void Write(Utf8JsonWriter writer, NestedCreateOptions? value, JsonSerializerOptions options) + { + // If the value is null, it is not written to the config file. + if (value is null) + { + return; + } + + writer.WritePropertyName("create"); + + writer.WriteStartObject(); + writer.WritePropertyName("enabled"); + writer.WriteBooleanValue(value.Enabled); + writer.WriteEndObject(); + } + } +} diff --git a/src/Config/Converters/NestedMutationOptionsConverter.cs b/src/Config/Converters/NestedMutationOptionsConverter.cs new file mode 100644 index 0000000000..f121e070dd --- /dev/null +++ b/src/Config/Converters/NestedMutationOptionsConverter.cs @@ -0,0 +1,88 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Text.Json.Serialization; +using Azure.DataApiBuilder.Config.ObjectModel; + +namespace Azure.DataApiBuilder.Config.Converters +{ + /// + /// Converter for the nested mutation options. + /// + internal class NestedMutationOptionsConverter : JsonConverter + { + + private readonly NestedCreateOptionsConverter _nestedCreateOptionsConverter; + + public NestedMutationOptionsConverter(JsonSerializerOptions options) + { + _nestedCreateOptionsConverter = options.GetConverter(typeof(NestedCreateOptions)) as NestedCreateOptionsConverter ?? + throw new JsonException("Failed to get nested create options converter"); + } + + /// + public override NestedMutationOptions? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + { + return null; + } + + if (reader.TokenType is JsonTokenType.StartObject) + { + NestedMutationOptions? nestedMutationOptions = null; + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + { + break; + } + + string? propertyName = reader.GetString(); + switch (propertyName) + { + case "create": + reader.Read(); + NestedCreateOptions? nestedCreateOptions = _nestedCreateOptionsConverter.Read(ref reader, typeToConvert, options); + if (nestedCreateOptions is not null) + { + nestedMutationOptions = new(nestedCreateOptions); + } + + break; + + default: + throw new JsonException($"Unexpected property {propertyName}"); + } + } + + return nestedMutationOptions; + } + + throw new JsonException("Failed to read the GraphQL Nested Mutation options"); + } + + /// + public override void Write(Utf8JsonWriter writer, NestedMutationOptions? value, JsonSerializerOptions options) + { + // If the nested mutation options is null, it is not written to the config file. + if (value is null) + { + return; + } + + writer.WritePropertyName("nested-mutations"); + + writer.WriteStartObject(); + + if (value.NestedCreateOptions is not null) + { + _nestedCreateOptionsConverter.Write(writer, value.NestedCreateOptions, options); + } + + writer.WriteEndObject(); + } + } +} diff --git a/src/Config/ObjectModel/GraphQLRuntimeOptions.cs b/src/Config/ObjectModel/GraphQLRuntimeOptions.cs index 9969835cb2..9033d269e6 100644 --- a/src/Config/ObjectModel/GraphQLRuntimeOptions.cs +++ b/src/Config/ObjectModel/GraphQLRuntimeOptions.cs @@ -3,7 +3,10 @@ namespace Azure.DataApiBuilder.Config.ObjectModel; -public record GraphQLRuntimeOptions(bool Enabled = true, string Path = GraphQLRuntimeOptions.DEFAULT_PATH, bool AllowIntrospection = true) +public record GraphQLRuntimeOptions(bool Enabled = true, + string Path = GraphQLRuntimeOptions.DEFAULT_PATH, + bool AllowIntrospection = true, + NestedMutationOptions? NestedMutationOptions = null) { public const string DEFAULT_PATH = "/graphql"; } diff --git a/src/Config/ObjectModel/NestedCreateOptions.cs b/src/Config/ObjectModel/NestedCreateOptions.cs new file mode 100644 index 0000000000..8439646766 --- /dev/null +++ b/src/Config/ObjectModel/NestedCreateOptions.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +namespace Azure.DataApiBuilder.Config.ObjectModel; + +/// +/// Options for nested create operations. +/// +/// Indicates whether nested create operation is enabled. +public class NestedCreateOptions +{ + /// + /// Indicates whether nested create operation is enabled. + /// + public bool Enabled; + + public NestedCreateOptions(bool enabled) + { + Enabled = enabled; + } +}; + diff --git a/src/Config/ObjectModel/NestedMutationOptions.cs b/src/Config/ObjectModel/NestedMutationOptions.cs new file mode 100644 index 0000000000..0cf6c05e3e --- /dev/null +++ b/src/Config/ObjectModel/NestedMutationOptions.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.DataApiBuilder.Config.ObjectModel; + +/// +/// Class that holds the options for all nested mutation operations. +/// +/// Options for nested create operation. +public class NestedMutationOptions +{ + // Options for nested create operation. + public NestedCreateOptions? NestedCreateOptions; + + public NestedMutationOptions(NestedCreateOptions? nestedCreateOptions = null) + { + NestedCreateOptions = nestedCreateOptions; + } + + /// + /// Helper function that checks if nested create operation is enabled. + /// + /// True/False depending on whether nested create operation is enabled/disabled. + public bool IsNestedCreateOperationEnabled() + { + return NestedCreateOptions is not null && NestedCreateOptions.Enabled; + } + +} diff --git a/src/Config/RuntimeConfigLoader.cs b/src/Config/RuntimeConfigLoader.cs index 0d64f35bdd..a597d94f8d 100644 --- a/src/Config/RuntimeConfigLoader.cs +++ b/src/Config/RuntimeConfigLoader.cs @@ -164,13 +164,15 @@ public static JsonSerializerOptions GetSerializationOptions( }; options.Converters.Add(new EnumMemberJsonEnumConverterFactory()); options.Converters.Add(new RestRuntimeOptionsConverterFactory()); - options.Converters.Add(new GraphQLRuntimeOptionsConverterFactory()); + options.Converters.Add(new GraphQLRuntimeOptionsConverterFactory(replaceEnvVar)); options.Converters.Add(new EntitySourceConverterFactory(replaceEnvVar)); options.Converters.Add(new EntityGraphQLOptionsConverterFactory(replaceEnvVar)); options.Converters.Add(new EntityRestOptionsConverterFactory(replaceEnvVar)); options.Converters.Add(new EntityActionConverterFactory()); options.Converters.Add(new DataSourceFilesConverter()); options.Converters.Add(new EntityCacheOptionsConverterFactory()); + options.Converters.Add(new NestedCreateOptionsConverter()); + options.Converters.Add(new NestedMutationOptionsConverter(options)); options.Converters.Add(new DataSourceConverterFactory(replaceEnvVar)); if (replaceEnvVar) diff --git a/src/Service.Tests/Configuration/ConfigurationTests.cs b/src/Service.Tests/Configuration/ConfigurationTests.cs index 096ada3a54..ddf4a782e4 100644 --- a/src/Service.Tests/Configuration/ConfigurationTests.cs +++ b/src/Service.Tests/Configuration/ConfigurationTests.cs @@ -70,6 +70,43 @@ public class ConfigurationTests private const int RETRY_COUNT = 5; private const int RETRY_WAIT_SECONDS = 1; + /// + /// + /// + public const string BOOK_ENTITY_JSON = @" + { + ""entities"": { + ""Book"": { + ""source"": { + ""object"": ""books"", + ""type"": ""table"" + }, + ""graphql"": { + ""enabled"": true, + ""type"": { + ""singular"": ""book"", + ""plural"": ""books"" + } + }, + ""rest"":{ + ""enabled"": true + }, + ""permissions"": [ + { + ""role"": ""anonymous"", + ""actions"": [ + { + ""action"": ""read"" + } + ] + } + ], + ""mappings"": null, + ""relationships"": null + } + } + }"; + /// /// A valid REST API request body with correct parameter types for all the fields. /// @@ -1622,6 +1659,119 @@ public async Task TestSPRestDefaultsForManuallyConstructedConfigs( } } + /// + /// Validates that deserialization of config file is successful for the following scenarios: + /// 1. Nested Mutations section is null + /// { + /// "nested-mutations": null + /// } + /// + /// 2. Nested Mutations section is empty. + /// { + /// "nested-mutations": {} + /// } + /// + /// 3. Create field within Nested Mutation section is null. + /// { + /// "nested-mutations": { + /// "create": null + /// } + /// } + /// + /// 4. Create field within Nested Mutation section is empty. + /// { + /// "nested-mutations": { + /// "create": {} + /// } + /// } + /// + /// For all the above mentioned scenarios, the expected value for NestedMutationOptions field is null. + /// + /// Base Config Json string. + [DataTestMethod] + [DataRow(TestHelper.BASE_CONFIG_NULL_NESTED_MUTATIONS_FIELD, DisplayName = "NestedMutationOptions field deserialized as null when nested mutation section is null")] + [DataRow(TestHelper.BASE_CONFIG_EMPTY_NESTED_MUTATIONS_FIELD, DisplayName = "NestedMutationOptions field deserialized as null when nested mutation section is empty")] + [DataRow(TestHelper.BASE_CONFIG_NULL_NESTED_CREATE_FIELD, DisplayName = "NestedMutationOptions field deserialized as null when create field within nested mutation section is null")] + [DataRow(TestHelper.BASE_CONFIG_EMPTY_NESTED_CREATE_FIELD, DisplayName = "NestedMutationOptions field deserialized as null when create field within nested mutation section is empty")] + public void ValidateDeserializationOfConfigWithNullOrEmptyInvalidNestedMutationSection(string baseConfig) + { + string configJson = TestHelper.AddPropertiesToJson(baseConfig, BOOK_ENTITY_JSON); + Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(configJson, out RuntimeConfig deserializedConfig)); + Assert.IsNotNull(deserializedConfig.Runtime); + Assert.IsNotNull(deserializedConfig.Runtime.GraphQL); + Assert.IsNull(deserializedConfig.Runtime.GraphQL.NestedMutationOptions); + } + + /// + /// Sanity check to validate that DAB engine starts successfully when used with a config file without the nested + /// mutations feature flag section. + /// The runtime graphql section of the config file used looks like this: + /// + /// "graphql": { + /// "path": "/graphql", + /// "allow-introspection": true + /// } + /// + /// Without the nested mutations feature flag section, DAB engine should be able to + /// 1. Successfully deserialize the config file without nested mutation section. + /// 2. Process REST and GraphQL API requests. + /// + /// + [TestMethod] + [TestCategory(TestCategory.MSSQL)] + public async Task SanityTestForRestAndGQLRequestsWithoutNestedMutationFeatureFlagSection() + { + // The configuration file is constructed by merging hard-coded JSON strings to simulate the scenario where users manually edit the + // configuration file (instead of using CLI). + string configJson = TestHelper.AddPropertiesToJson(TestHelper.BASE_CONFIG, BOOK_ENTITY_JSON); + Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(configJson, out RuntimeConfig deserializedConfig, logger: null, GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL))); + string configFileName = "custom-config.json"; + File.WriteAllText(configFileName, deserializedConfig.ToJson()); + string[] args = new[] + { + $"--ConfigFileName={configFileName}" + }; + + using (TestServer server = new(Program.CreateWebHostBuilder(args))) + using (HttpClient client = server.CreateClient()) + { + try + { + + // Perform a REST GET API request to validate that REST GET API requests are executed correctly. + HttpRequestMessage restRequest = new(HttpMethod.Get, "api/Book"); + HttpResponseMessage restResponse = await client.SendAsync(restRequest); + Assert.AreEqual(HttpStatusCode.OK, restResponse.StatusCode); + + // Perform a GraphQL API request to validate that DAB engine executes GraphQL requests successfully. + string query = @"{ + book_by_pk(id: 1) { + id, + title, + publisher_id + } + }"; + + object payload = new { query }; + + HttpRequestMessage graphQLRequest = new(HttpMethod.Post, "/graphql") + { + Content = JsonContent.Create(payload) + }; + + HttpResponseMessage graphQLResponse = await client.SendAsync(graphQLRequest); + Assert.AreEqual(HttpStatusCode.OK, graphQLResponse.StatusCode); + Assert.IsNotNull(graphQLResponse.Content); + string body = await graphQLResponse.Content.ReadAsStringAsync(); + Assert.IsFalse(body.Contains("errors")); + } + catch (Exception ex) + { + Assert.Fail($"Unexpected exception : {ex}"); + } + } + } + /// /// Test to validate that when an entity which will return a paginated response is queried, and a custom runtime base route is configured in the runtime configuration, /// then the generated nextLink in the response would contain the rest base-route just before the rest path. For the subsequent query, the rest base-route will be trimmed diff --git a/src/Service.Tests/Multidab-config.MsSql.json b/src/Service.Tests/Multidab-config.MsSql.json index 00aaac2ed2..9f05161a83 100644 --- a/src/Service.Tests/Multidab-config.MsSql.json +++ b/src/Service.Tests/Multidab-config.MsSql.json @@ -15,7 +15,12 @@ "graphql": { "enabled": true, "path": "/graphql", - "allow-introspection": true + "allow-introspection": true, + "nested-mutations": { + "create": { + "enabled": true + } + } }, "host": { "cors": { diff --git a/src/Service.Tests/TestHelper.cs b/src/Service.Tests/TestHelper.cs index b1a1137b62..466ca311ef 100644 --- a/src/Service.Tests/TestHelper.cs +++ b/src/Service.Tests/TestHelper.cs @@ -194,6 +194,95 @@ public static RuntimeConfig AddMissingEntitiesToConfig(RuntimeConfig config, str ""entities"": {}" + "}"; + /// + /// An empty entities section of the config file. This is used in constructing config json strings utilized for testing. + /// + public const string EMPTY_ENTITIES_CONFIG_JSON = + @" + ""entities"": {} + "; + + /// + /// A json string with Runtime Rest and GraphQL options. This is used in constructing config json strings utilized for testing. + /// + public const string RUNTIME_REST_GRAPHQL_OPTIONS_CONFIG_JSON = + "{" + + SAMPLE_SCHEMA_DATA_SOURCE + "," + + @" + ""runtime"": { + ""rest"": { + ""path"": ""/api"" + }, + ""graphql"": { + ""path"": ""/graphql"", + ""allow-introspection"": true,"; + + /// + /// A json string with host and empty entity options. This is used in constructing config json strings utilized for testing. + /// + public const string HOST_AND_ENTITY_OPTIONS_CONFIG_JSON = + @" + ""host"": { + ""mode"": ""development"", + ""cors"": { + ""origins"": [""http://localhost:5000""], + ""allow-credentials"": false + }, + ""authentication"": { + ""provider"": ""StaticWebApps"" + } + } + }" + "," + + EMPTY_ENTITIES_CONFIG_JSON + + "}"; + + /// + /// A minimal valid config json with nested mutations section as null. + /// + public const string BASE_CONFIG_NULL_NESTED_MUTATIONS_FIELD = + RUNTIME_REST_GRAPHQL_OPTIONS_CONFIG_JSON + + @" + ""nested-mutations"": null + }," + + HOST_AND_ENTITY_OPTIONS_CONFIG_JSON; + + /// + /// A minimal valid config json with an empty nested mutations section. + /// + public const string BASE_CONFIG_EMPTY_NESTED_MUTATIONS_FIELD = + + RUNTIME_REST_GRAPHQL_OPTIONS_CONFIG_JSON + + @" + ""nested-mutations"": {} + }," + + HOST_AND_ENTITY_OPTIONS_CONFIG_JSON; + + /// + /// A minimal valid config json with the create field within nested mutation as null. + /// + public const string BASE_CONFIG_NULL_NESTED_CREATE_FIELD = + + RUNTIME_REST_GRAPHQL_OPTIONS_CONFIG_JSON + + @" + ""nested-mutations"": { + ""create"": null + } + }," + + HOST_AND_ENTITY_OPTIONS_CONFIG_JSON; + + /// + /// A minimal valid config json with an empty create field within nested mutation. + /// + public const string BASE_CONFIG_EMPTY_NESTED_CREATE_FIELD = + + RUNTIME_REST_GRAPHQL_OPTIONS_CONFIG_JSON + + @" + ""nested-mutations"": { + ""create"": {} + } + }," + + HOST_AND_ENTITY_OPTIONS_CONFIG_JSON; + public static RuntimeConfigProvider GenerateInMemoryRuntimeConfigProvider(RuntimeConfig runtimeConfig) { MockFileSystem fileSystem = new(); diff --git a/src/Service.Tests/Unittests/RuntimeConfigLoaderJsonDeserializerTests.cs b/src/Service.Tests/Unittests/RuntimeConfigLoaderJsonDeserializerTests.cs index e4366159eb..2f950b20d0 100644 --- a/src/Service.Tests/Unittests/RuntimeConfigLoaderJsonDeserializerTests.cs +++ b/src/Service.Tests/Unittests/RuntimeConfigLoaderJsonDeserializerTests.cs @@ -422,7 +422,12 @@ public static string GetModifiedJsonString(string[] reps, string enumString) ""graphql"": { ""enabled"": true, ""path"": """ + reps[++index % reps.Length] + @""", - ""allow-introspection"": true + ""allow-introspection"": true, + ""nested-mutations"": { + ""create"": { + ""enabled"": false + } + } }, ""host"": { ""mode"": ""development"", diff --git a/src/Service.Tests/dab-config.MsSql.json b/src/Service.Tests/dab-config.MsSql.json index dbec0174ef..0453d1ecf1 100644 --- a/src/Service.Tests/dab-config.MsSql.json +++ b/src/Service.Tests/dab-config.MsSql.json @@ -16,7 +16,12 @@ "graphql": { "enabled": true, "path": "/graphql", - "allow-introspection": true + "allow-introspection": true, + "nested-mutations": { + "create": { + "enabled": true + } + } }, "host": { "cors": { From b1d5acaf5281753f2e1c89410abd8e6f44c4eccf Mon Sep 17 00:00:00 2001 From: Shyam Sundar J Date: Wed, 20 Mar 2024 09:56:18 +0530 Subject: [PATCH 4/8] Rename nested-mutations and nested-create to multiple-mutations and multiple-create respectively (#2103) ## Why make this change? - Closes https://github.com/Azure/data-api-builder/issues/2090 - Renames `nested-mutations` and `nested-create` to `multiple-mutations` and `multiple-create` respectively. - Nested Mutations has a significantly different meaning in the graphql specs (as explained in issue https://github.com/graphql/graphql-spec/issues/252) compared to what we are trying to achieve. Hence, the decision to rename. ## What is this change? - All references to nested mutations/nested create - option name, field names in config JSON, class names, comments in code have been renamed. ## How was this tested? - Unit and Integration tests added as part of https://github.com/Azure/data-api-builder/pull/1983 has been updated to validate the rename of CLI option, property in the config file, etc. ## Sample Commands ![image](https://github.com/Azure/data-api-builder/assets/11196553/7815bdb8-b0ca-409c-9b28-1101d5639130) --- schemas/dab.draft.schema.json | 8 +- src/Cli.Tests/EndToEndTests.cs | 74 +++++++++---------- src/Cli.Tests/InitTests.cs | 58 +++++++-------- ...tionOptions_0546bef37027a950.verified.txt} | 0 ...tionOptions_0ac567dd32a2e8f5.verified.txt} | 0 ...tionOptions_0c06949221514e77.verified.txt} | 4 +- ...tionOptions_18667ab7db033e9d.verified.txt} | 0 ...tionOptions_2f42f44c328eb020.verified.txt} | 0 ...tionOptions_3243d3f3441fdcc1.verified.txt} | 0 ...tionOptions_53350b8b47df2112.verified.txt} | 0 ...tionOptions_6584e0ec46b8a11d.verified.txt} | 0 ...tionOptions_81cc88db3d4eecfb.verified.txt} | 4 +- ...tionOptions_8ea187616dbb5577.verified.txt} | 0 ...tionOptions_905845c29560a3ef.verified.txt} | 0 ...tionOptions_b2fd24fab5b80917.verified.txt} | 0 ...tionOptions_bd7cd088755287c9.verified.txt} | 0 ...tionOptions_d2eccba2f836b380.verified.txt} | 0 ...tionOptions_d463eed7fe5e4bbe.verified.txt} | 0 ...tionOptions_d5520dd5c33f7b8d.verified.txt} | 0 ...tionOptions_eab4a6010e602b59.verified.txt} | 0 ...tionOptions_ecaa688829b4030e.verified.txt} | 0 ...ationOptions_35696f184b0ec6f0.verified.txt | 35 --------- src/Cli/Commands/InitOptions.cs | 8 +- src/Cli/ConfigGenerator.cs | 30 ++++---- .../GraphQLRuntimeOptionsConverterFactory.cs | 18 ++--- ...r.cs => MultipleCreateOptionsConverter.cs} | 16 ++-- ...cs => MultipleMutationOptionsConverter.cs} | 36 ++++----- .../ObjectModel/GraphQLRuntimeOptions.cs | 2 +- .../ObjectModel/MultipleCreateOptions.cs | 21 ++++++ .../ObjectModel/MultipleMutationOptions.cs | 20 +++++ src/Config/ObjectModel/NestedCreateOptions.cs | 21 ------ .../ObjectModel/NestedMutationOptions.cs | 29 -------- src/Config/RuntimeConfigLoader.cs | 4 +- .../Configuration/ConfigurationTests.cs | 38 +++++----- src/Service.Tests/Multidab-config.MsSql.json | 2 +- src/Service.Tests/TestHelper.cs | 24 +++--- ...untimeConfigLoaderJsonDeserializerTests.cs | 2 +- src/Service.Tests/dab-config.MsSql.json | 2 +- 38 files changed, 206 insertions(+), 250 deletions(-) rename src/Cli.Tests/Snapshots/{InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_2be9ac1b7d981cde.verified.txt => InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_0546bef37027a950.verified.txt} (100%) rename src/Cli.Tests/Snapshots/{InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_1211ad099a77f7c4.verified.txt => InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_0ac567dd32a2e8f5.verified.txt} (100%) rename src/Cli.Tests/Snapshots/{InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_adc642ef89cb6d18.verified.txt => InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_0c06949221514e77.verified.txt} (90%) rename src/Cli.Tests/Snapshots/{InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_215291b2b7ff2cb4.verified.txt => InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_18667ab7db033e9d.verified.txt} (100%) rename src/Cli.Tests/Snapshots/{InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_92ba6ec2f08a3060.verified.txt => InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_2f42f44c328eb020.verified.txt} (100%) rename src/Cli.Tests/Snapshots/{InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_7c4d5358dc16f63f.verified.txt => InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_3243d3f3441fdcc1.verified.txt} (100%) rename src/Cli.Tests/Snapshots/{InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_1a73d3cfd329f922.verified.txt => InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_53350b8b47df2112.verified.txt} (100%) rename src/Cli.Tests/Snapshots/{InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_09cf40a5c545de68.verified.txt => InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_6584e0ec46b8a11d.verified.txt} (100%) rename src/Cli.Tests/Snapshots/{InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_17721ef496526b3e.verified.txt => InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_81cc88db3d4eecfb.verified.txt} (90%) rename src/Cli.Tests/Snapshots/{InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_8459925dada37738.verified.txt => InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_8ea187616dbb5577.verified.txt} (100%) rename src/Cli.Tests/Snapshots/{InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_9efd9a8a0ff47434.verified.txt => InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_905845c29560a3ef.verified.txt} (100%) rename src/Cli.Tests/Snapshots/{InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_181195e2fbe991a8.verified.txt => InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_b2fd24fab5b80917.verified.txt} (100%) rename src/Cli.Tests/Snapshots/{InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_388c095980b1b53f.verified.txt => InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_bd7cd088755287c9.verified.txt} (100%) rename src/Cli.Tests/Snapshots/{InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_384e318d9ed21c9c.verified.txt => InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_d2eccba2f836b380.verified.txt} (100%) rename src/Cli.Tests/Snapshots/{InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_d1e814ccd5d8b8e8.verified.txt => InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_d463eed7fe5e4bbe.verified.txt} (100%) rename src/Cli.Tests/Snapshots/{InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_6fb51a691160163b.verified.txt => InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_d5520dd5c33f7b8d.verified.txt} (100%) rename src/Cli.Tests/Snapshots/{InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_c0ee0e6a86fa0b7e.verified.txt => InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_eab4a6010e602b59.verified.txt} (100%) rename src/Cli.Tests/Snapshots/{InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_ef2f00a9e204e114.verified.txt => InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_ecaa688829b4030e.verified.txt} (100%) delete mode 100644 src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_35696f184b0ec6f0.verified.txt rename src/Config/Converters/{NestedCreateOptionsConverter.cs => MultipleCreateOptionsConverter.cs} (76%) rename src/Config/Converters/{NestedMutationOptionsConverter.cs => MultipleMutationOptionsConverter.cs} (51%) create mode 100644 src/Config/ObjectModel/MultipleCreateOptions.cs create mode 100644 src/Config/ObjectModel/MultipleMutationOptions.cs delete mode 100644 src/Config/ObjectModel/NestedCreateOptions.cs delete mode 100644 src/Config/ObjectModel/NestedMutationOptions.cs diff --git a/schemas/dab.draft.schema.json b/schemas/dab.draft.schema.json index 4159308c18..39bde040c0 100644 --- a/schemas/dab.draft.schema.json +++ b/schemas/dab.draft.schema.json @@ -176,19 +176,19 @@ "type": "boolean", "description": "Allow enabling/disabling GraphQL requests for all entities." }, - "nested-mutations": { + "multiple-mutations": { "type": "object", - "description": "Configuration properties for nested mutation operations", + "description": "Configuration properties for multiple mutation operations", "additionalProperties": false, "properties": { "create":{ "type": "object", - "description": "Options for nested create operations", + "description": "Options for multiple create operations", "additionalProperties": false, "properties": { "enabled": { "type": "boolean", - "description": "Allow enabling/disabling nested create operations for all entities.", + "description": "Allow enabling/disabling multiple create operations for all entities.", "default": false } } diff --git a/src/Cli.Tests/EndToEndTests.cs b/src/Cli.Tests/EndToEndTests.cs index f15b744d36..4462f52c4d 100644 --- a/src/Cli.Tests/EndToEndTests.cs +++ b/src/Cli.Tests/EndToEndTests.cs @@ -132,49 +132,49 @@ public void TestInitializingRestAndGraphQLGlobalSettings() } /// - /// Test to validate the usage of --graphql.nested-create.enabled option of the init command for all database types. + /// Test to validate the usage of --graphql.multiple-create.enabled option of the init command for all database types. /// /// 1. Behavior for database types other than MsSQL: - /// - Irrespective of whether the --graphql.nested-create.enabled option is used or not, fields related to nested-create will NOT be written to the config file. - /// - As a result, after deserialization of such a config file, the Runtime.GraphQL.NestedMutationOptions is expected to be null. + /// - Irrespective of whether the --graphql.multiple-create.enabled option is used or not, fields related to multiple-create will NOT be written to the config file. + /// - As a result, after deserialization of such a config file, the Runtime.GraphQL.MultipleMutationOptions is expected to be null. /// 2. Behavior for MsSQL database type: /// - /// a. When --graphql.nested-create.enabled option is used - /// - In this case, the fields related to nested mutation and nested create operations will be written to the config file. - /// "nested-mutations": { + /// a. When --graphql.multiple-create.enabled option is used + /// - In this case, the fields related to multiple mutation and multiple create operations will be written to the config file. + /// "multiple-mutations": { /// "create": { /// "enabled": true/false /// } /// } - /// After deserializing such a config file, the Runtime.GraphQL.NestedMutationOptions is expected to be non-null and the value of the "enabled" field is expected to be the same as the value passed in the init command. + /// After deserializing such a config file, the Runtime.GraphQL.MultipleMutationOptions is expected to be non-null and the value of the "enabled" field is expected to be the same as the value passed in the init command. /// - /// b. When --graphql.nested-create.enabled option is not used - /// - In this case, fields related to nested mutation and nested create operations will NOT be written to the config file. - /// - As a result, after deserialization of such a config file, the Runtime.GraphQL.NestedMutationOptions is expected to be null. + /// b. When --graphql.multiple-create.enabled option is not used + /// - In this case, fields related to multiple mutation and multiple create operations will NOT be written to the config file. + /// - As a result, after deserialization of such a config file, the Runtime.GraphQL.MultipleMutationOptions is expected to be null. /// /// - /// Value interpreted by the CLI for '--graphql.nested-create.enabled' option of the init command. + /// Value interpreted by the CLI for '--graphql.multiple-create.enabled' option of the init command. /// When not used, CLI interprets the value for the option as CliBool.None /// When used with true/false, CLI interprets the value as CliBool.True/CliBool.False respectively. /// - /// Expected value for the nested create enabled flag in the config file. + /// Expected value for the multiple create enabled flag in the config file. [DataTestMethod] - [DataRow(CliBool.True, "mssql", DatabaseType.MSSQL, DisplayName = "Init command with '--graphql.nested-create.enabled true' for MsSql database type")] - [DataRow(CliBool.False, "mssql", DatabaseType.MSSQL, DisplayName = "Init command with '--graphql.nested-create.enabled false' for MsSql database type")] - [DataRow(CliBool.None, "mssql", DatabaseType.MSSQL, DisplayName = "Init command without '--graphql.nested-create.enabled' option for MsSql database type")] - [DataRow(CliBool.True, "mysql", DatabaseType.MySQL, DisplayName = "Init command with '--graphql.nested-create.enabled true' for MySql database type")] - [DataRow(CliBool.False, "mysql", DatabaseType.MySQL, DisplayName = "Init command with '--graphql.nested-create.enabled false' for MySql database type")] - [DataRow(CliBool.None, "mysql", DatabaseType.MySQL, DisplayName = "Init command without '--graphql.nested-create.enabled' option for MySql database type")] - [DataRow(CliBool.True, "postgresql", DatabaseType.PostgreSQL, DisplayName = "Init command with '--graphql.nested-create.enabled true' for PostgreSql database type")] - [DataRow(CliBool.False, "postgresql", DatabaseType.PostgreSQL, DisplayName = "Init command with '--graphql.nested-create.enabled false' for PostgreSql database type")] - [DataRow(CliBool.None, "postgresql", DatabaseType.PostgreSQL, DisplayName = "Init command without '--graphql.nested-create.enabled' option for PostgreSql database type")] - [DataRow(CliBool.True, "dwsql", DatabaseType.DWSQL, DisplayName = "Init command with '--graphql.nested-create.enabled true' for dwsql database type")] - [DataRow(CliBool.False, "dwsql", DatabaseType.DWSQL, DisplayName = "Init command with '--graphql.nested-create.enabled false' for dwsql database type")] - [DataRow(CliBool.None, "dwsql", DatabaseType.DWSQL, DisplayName = "Init command without '--graphql.nested-create.enabled' option for dwsql database type")] - [DataRow(CliBool.True, "cosmosdb_nosql", DatabaseType.CosmosDB_NoSQL, DisplayName = "Init command with '--graphql.nested-create.enabled true' for cosmosdb_nosql database type")] - [DataRow(CliBool.False, "cosmosdb_nosql", DatabaseType.CosmosDB_NoSQL, DisplayName = "Init command with '--graphql.nested-create.enabled false' for cosmosdb_nosql database type")] - [DataRow(CliBool.None, "cosmosdb_nosql", DatabaseType.CosmosDB_NoSQL, DisplayName = "Init command without '--graphql.nested-create.enabled' option for cosmosdb_nosql database type")] - public void TestEnablingNestedCreateOperation(CliBool isNestedCreateEnabled, string dbType, DatabaseType expectedDbType) + [DataRow(CliBool.True, "mssql", DatabaseType.MSSQL, DisplayName = "Init command with '--graphql.multiple-create.enabled true' for MsSql database type")] + [DataRow(CliBool.False, "mssql", DatabaseType.MSSQL, DisplayName = "Init command with '--graphql.multiple-create.enabled false' for MsSql database type")] + [DataRow(CliBool.None, "mssql", DatabaseType.MSSQL, DisplayName = "Init command without '--graphql.multiple-create.enabled' option for MsSql database type")] + [DataRow(CliBool.True, "mysql", DatabaseType.MySQL, DisplayName = "Init command with '--graphql.multiple-create.enabled true' for MySql database type")] + [DataRow(CliBool.False, "mysql", DatabaseType.MySQL, DisplayName = "Init command with '--graphql.multiple-create.enabled false' for MySql database type")] + [DataRow(CliBool.None, "mysql", DatabaseType.MySQL, DisplayName = "Init command without '--graphql.multiple-create.enabled' option for MySql database type")] + [DataRow(CliBool.True, "postgresql", DatabaseType.PostgreSQL, DisplayName = "Init command with '--graphql.multiple-create.enabled true' for PostgreSql database type")] + [DataRow(CliBool.False, "postgresql", DatabaseType.PostgreSQL, DisplayName = "Init command with '--graphql.multiple-create.enabled false' for PostgreSql database type")] + [DataRow(CliBool.None, "postgresql", DatabaseType.PostgreSQL, DisplayName = "Init command without '--graphql.multiple-create.enabled' option for PostgreSql database type")] + [DataRow(CliBool.True, "dwsql", DatabaseType.DWSQL, DisplayName = "Init command with '--graphql.multiple-create.enabled true' for dwsql database type")] + [DataRow(CliBool.False, "dwsql", DatabaseType.DWSQL, DisplayName = "Init command with '--graphql.multiple-create.enabled false' for dwsql database type")] + [DataRow(CliBool.None, "dwsql", DatabaseType.DWSQL, DisplayName = "Init command without '--graphql.multiple-create.enabled' option for dwsql database type")] + [DataRow(CliBool.True, "cosmosdb_nosql", DatabaseType.CosmosDB_NoSQL, DisplayName = "Init command with '--graphql.multiple-create.enabled true' for cosmosdb_nosql database type")] + [DataRow(CliBool.False, "cosmosdb_nosql", DatabaseType.CosmosDB_NoSQL, DisplayName = "Init command with '--graphql.multiple-create.enabled false' for cosmosdb_nosql database type")] + [DataRow(CliBool.None, "cosmosdb_nosql", DatabaseType.CosmosDB_NoSQL, DisplayName = "Init command without '--graphql.multiple-create.enabled' option for cosmosdb_nosql database type")] + public void TestEnablingMultipleCreateOperation(CliBool isMultipleCreateEnabled, string dbType, DatabaseType expectedDbType) { List args = new() { "init", "-c", TEST_RUNTIME_CONFIG_FILE, "--connection-string", SAMPLE_TEST_CONN_STRING, "--database-type", dbType }; @@ -185,10 +185,10 @@ public void TestEnablingNestedCreateOperation(CliBool isNestedCreateEnabled, str args.AddRange(cosmosNoSqlArgs); } - if (isNestedCreateEnabled is not CliBool.None) + if (isMultipleCreateEnabled is not CliBool.None) { - args.Add("--graphql.nested-create.enabled"); - args.Add(isNestedCreateEnabled.ToString()!); + args.Add("--graphql.multiple-create.enabled"); + args.Add(isMultipleCreateEnabled.ToString()!); } Program.Execute(args.ToArray(), _cliLogger!, _fileSystem!, _runtimeConfigLoader!); @@ -202,16 +202,16 @@ public void TestEnablingNestedCreateOperation(CliBool isNestedCreateEnabled, str Assert.AreEqual(expectedDbType, runtimeConfig.DataSource.DatabaseType); Assert.IsNotNull(runtimeConfig.Runtime); Assert.IsNotNull(runtimeConfig.Runtime.GraphQL); - if (runtimeConfig.DataSource.DatabaseType is DatabaseType.MSSQL && isNestedCreateEnabled is not CliBool.None) + if (runtimeConfig.DataSource.DatabaseType is DatabaseType.MSSQL && isMultipleCreateEnabled is not CliBool.None) { - Assert.IsNotNull(runtimeConfig.Runtime.GraphQL.NestedMutationOptions); - Assert.IsNotNull(runtimeConfig.Runtime.GraphQL.NestedMutationOptions.NestedCreateOptions); - bool expectedValueForNestedCreateEnabled = isNestedCreateEnabled == CliBool.True; - Assert.AreEqual(expectedValueForNestedCreateEnabled, runtimeConfig.Runtime.GraphQL.NestedMutationOptions.NestedCreateOptions.Enabled); + Assert.IsNotNull(runtimeConfig.Runtime.GraphQL.MultipleMutationOptions); + Assert.IsNotNull(runtimeConfig.Runtime.GraphQL.MultipleMutationOptions.MultipleCreateOptions); + bool expectedValueForMultipleCreateEnabled = isMultipleCreateEnabled == CliBool.True; + Assert.AreEqual(expectedValueForMultipleCreateEnabled, runtimeConfig.Runtime.GraphQL.MultipleMutationOptions.MultipleCreateOptions.Enabled); } else { - Assert.IsNull(runtimeConfig.Runtime.GraphQL.NestedMutationOptions, message: "NestedMutationOptions is expected to be null because a) DB type is not MsSQL or b) Either --graphql.nested-create.enabled option was not used or no value was provided."); + Assert.IsNull(runtimeConfig.Runtime.GraphQL.MultipleMutationOptions, message: "MultipleMutationOptions is expected to be null because a) DB type is not MsSQL or b) Either --graphql.multiple-create.enabled option was not used or no value was provided."); } } diff --git a/src/Cli.Tests/InitTests.cs b/src/Cli.Tests/InitTests.cs index bfd0a7a19c..b8ea6bcb74 100644 --- a/src/Cli.Tests/InitTests.cs +++ b/src/Cli.Tests/InitTests.cs @@ -410,44 +410,44 @@ public Task GraphQLPathWithoutStartingSlashWillHaveItAdded() } /// - /// Test to validate the contents of the config file generated when init command is used with --graphql.nested-create.enabled flag option for different database types. + /// Test to validate the contents of the config file generated when init command is used with --graphql.multiple-create.enabled flag option for different database types. /// /// 1. For database types other than MsSQL: - /// - Irrespective of whether the --graphql.nested-create.enabled option is used or not, fields related to nested-create will NOT be written to the config file. + /// - Irrespective of whether the --graphql.multiple-create.enabled option is used or not, fields related to multiple-create will NOT be written to the config file. /// /// 2. For MsSQL database type: - /// a. When --graphql.nested-create.enabled option is used - /// - In this case, the fields related to nested mutation and nested create operations will be written to the config file. - /// "nested-mutations": { + /// a. When --graphql.multiple-create.enabled option is used + /// - In this case, the fields related to multiple mutation and multiple create operations will be written to the config file. + /// "multiple-mutations": { /// "create": { /// "enabled": true/false /// } /// } /// - /// b. When --graphql.nested-create.enabled option is not used - /// - In this case, fields related to nested mutation and nested create operations will NOT be written to the config file. + /// b. When --graphql.multiple-create.enabled option is not used + /// - In this case, fields related to multiple mutation and multiple create operations will NOT be written to the config file. /// /// [DataTestMethod] - [DataRow(DatabaseType.MSSQL, CliBool.True, DisplayName = "Init command with '--graphql.nested-create.enabled true' for MsSQL database type")] - [DataRow(DatabaseType.MSSQL, CliBool.False, DisplayName = "Init command with '--graphql.nested-create.enabled false' for MsSQL database type")] - [DataRow(DatabaseType.MSSQL, CliBool.None, DisplayName = "Init command without '--graphql.nested-create.enabled' option for MsSQL database type")] - [DataRow(DatabaseType.PostgreSQL, CliBool.True, DisplayName = "Init command with '--graphql.nested-create.enabled true' for PostgreSQL database type")] - [DataRow(DatabaseType.PostgreSQL, CliBool.False, DisplayName = "Init command with '--graphql.nested-create.enabled false' for PostgreSQL database type")] - [DataRow(DatabaseType.PostgreSQL, CliBool.None, DisplayName = "Init command without '--graphql.nested-create.enabled' option for PostgreSQL database type")] - [DataRow(DatabaseType.MySQL, CliBool.True, DisplayName = "Init command with '--graphql.nested-create.enabled true' for MySQL database type")] - [DataRow(DatabaseType.MySQL, CliBool.False, DisplayName = "Init command with '--graphql.nested-create.enabled false' for MySQL database type")] - [DataRow(DatabaseType.MySQL, CliBool.None, DisplayName = "Init command without '--graphql.nested-create.enabled' option for MySQL database type")] - [DataRow(DatabaseType.CosmosDB_NoSQL, CliBool.True, DisplayName = "Init command with '--graphql.nested-create.enabled true' for CosmosDB_NoSQL database type")] - [DataRow(DatabaseType.CosmosDB_NoSQL, CliBool.False, DisplayName = "Init command with '--graphql.nested-create.enabled false' for CosmosDB_NoSQL database type")] - [DataRow(DatabaseType.CosmosDB_NoSQL, CliBool.None, DisplayName = "Init command without '--graphql.nested-create.enabled' option for CosmosDB_NoSQL database type")] - [DataRow(DatabaseType.CosmosDB_PostgreSQL, CliBool.True, DisplayName = "Init command with '--graphql.nested-create.enabled true' for CosmosDB_PostgreSQL database type")] - [DataRow(DatabaseType.CosmosDB_PostgreSQL, CliBool.False, DisplayName = "Init command with '--graphql.nested-create.enabled false' for CosmosDB_PostgreSQL database type")] - [DataRow(DatabaseType.CosmosDB_PostgreSQL, CliBool.None, DisplayName = "Init command without '--graphql.nested-create.enabled' option for CosmosDB_PostgreSQL database type")] - [DataRow(DatabaseType.DWSQL, CliBool.True, DisplayName = "Init command with '--graphql.nested-create.enabled true' for DWSQL database type")] - [DataRow(DatabaseType.DWSQL, CliBool.False, DisplayName = "Init command with '--graphql.nested-create.enabled false' for DWSQL database type")] - [DataRow(DatabaseType.DWSQL, CliBool.None, DisplayName = "Init command without '--graphql.nested-create.enabled' option for DWSQL database type")] - public Task VerifyCorrectConfigGenerationWithNestedMutationOptions(DatabaseType databaseType, CliBool isNestedCreateEnabled) + [DataRow(DatabaseType.MSSQL, CliBool.True, DisplayName = "Init command with '--graphql.multiple-create.enabled true' for MsSQL database type")] + [DataRow(DatabaseType.MSSQL, CliBool.False, DisplayName = "Init command with '--graphql.multiple-create.enabled false' for MsSQL database type")] + [DataRow(DatabaseType.MSSQL, CliBool.None, DisplayName = "Init command without '--graphql.multiple-create.enabled' option for MsSQL database type")] + [DataRow(DatabaseType.PostgreSQL, CliBool.True, DisplayName = "Init command with '--graphql.multiple-create.enabled true' for PostgreSQL database type")] + [DataRow(DatabaseType.PostgreSQL, CliBool.False, DisplayName = "Init command with '--graphql.multiple-create.enabled false' for PostgreSQL database type")] + [DataRow(DatabaseType.PostgreSQL, CliBool.None, DisplayName = "Init command without '--graphql.multiple-create.enabled' option for PostgreSQL database type")] + [DataRow(DatabaseType.MySQL, CliBool.True, DisplayName = "Init command with '--graphql.multiple-create.enabled true' for MySQL database type")] + [DataRow(DatabaseType.MySQL, CliBool.False, DisplayName = "Init command with '--graphql.multiple-create.enabled false' for MySQL database type")] + [DataRow(DatabaseType.MySQL, CliBool.None, DisplayName = "Init command without '--graphql.multiple-create.enabled' option for MySQL database type")] + [DataRow(DatabaseType.CosmosDB_NoSQL, CliBool.True, DisplayName = "Init command with '--graphql.multiple-create.enabled true' for CosmosDB_NoSQL database type")] + [DataRow(DatabaseType.CosmosDB_NoSQL, CliBool.False, DisplayName = "Init command with '--graphql.multiple-create.enabled false' for CosmosDB_NoSQL database type")] + [DataRow(DatabaseType.CosmosDB_NoSQL, CliBool.None, DisplayName = "Init command without '--graphql.multiple-create.enabled' option for CosmosDB_NoSQL database type")] + [DataRow(DatabaseType.CosmosDB_PostgreSQL, CliBool.True, DisplayName = "Init command with '--graphql.multiple-create.enabled true' for CosmosDB_PostgreSQL database type")] + [DataRow(DatabaseType.CosmosDB_PostgreSQL, CliBool.False, DisplayName = "Init command with '--graphql.multiple-create.enabled false' for CosmosDB_PostgreSQL database type")] + [DataRow(DatabaseType.CosmosDB_PostgreSQL, CliBool.None, DisplayName = "Init command without '--graphql.multiple-create.enabled' option for CosmosDB_PostgreSQL database type")] + [DataRow(DatabaseType.DWSQL, CliBool.True, DisplayName = "Init command with '--graphql.multiple-create.enabled true' for DWSQL database type")] + [DataRow(DatabaseType.DWSQL, CliBool.False, DisplayName = "Init command with '--graphql.multiple-create.enabled false' for DWSQL database type")] + [DataRow(DatabaseType.DWSQL, CliBool.None, DisplayName = "Init command without '--graphql.multiple-create.enabled' option for DWSQL database type")] + public Task VerifyCorrectConfigGenerationWithMultipleMutationOptions(DatabaseType databaseType, CliBool isMultipleCreateEnabled) { InitOptions options; @@ -468,7 +468,7 @@ public Task VerifyCorrectConfigGenerationWithNestedMutationOptions(DatabaseType authenticationProvider: EasyAuthType.StaticWebApps.ToString(), restPath: "rest-api", config: TEST_RUNTIME_CONFIG_FILE, - nestedCreateOperationEnabled: isNestedCreateEnabled); + multipleCreateOperationEnabled: isMultipleCreateEnabled); } else { @@ -484,11 +484,11 @@ public Task VerifyCorrectConfigGenerationWithNestedMutationOptions(DatabaseType authenticationProvider: EasyAuthType.StaticWebApps.ToString(), restPath: "rest-api", config: TEST_RUNTIME_CONFIG_FILE, - nestedCreateOperationEnabled: isNestedCreateEnabled); + multipleCreateOperationEnabled: isMultipleCreateEnabled); } VerifySettings verifySettings = new(); - verifySettings.UseHashedParameters(databaseType, isNestedCreateEnabled); + verifySettings.UseHashedParameters(databaseType, isMultipleCreateEnabled); return ExecuteVerifyTest(options, verifySettings); } diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_2be9ac1b7d981cde.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_0546bef37027a950.verified.txt similarity index 100% rename from src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_2be9ac1b7d981cde.verified.txt rename to src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_0546bef37027a950.verified.txt diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_1211ad099a77f7c4.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_0ac567dd32a2e8f5.verified.txt similarity index 100% rename from src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_1211ad099a77f7c4.verified.txt rename to src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_0ac567dd32a2e8f5.verified.txt diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_adc642ef89cb6d18.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_0c06949221514e77.verified.txt similarity index 90% rename from src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_adc642ef89cb6d18.verified.txt rename to src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_0c06949221514e77.verified.txt index 65cf6b8748..62fc407842 100644 --- a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_adc642ef89cb6d18.verified.txt +++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_0c06949221514e77.verified.txt @@ -15,8 +15,8 @@ Enabled: true, Path: /graphql, AllowIntrospection: true, - NestedMutationOptions: { - NestedCreateOptions: { + MultipleMutationOptions: { + MultipleCreateOptions: { Enabled: false } } diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_215291b2b7ff2cb4.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_18667ab7db033e9d.verified.txt similarity index 100% rename from src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_215291b2b7ff2cb4.verified.txt rename to src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_18667ab7db033e9d.verified.txt diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_92ba6ec2f08a3060.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_2f42f44c328eb020.verified.txt similarity index 100% rename from src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_92ba6ec2f08a3060.verified.txt rename to src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_2f42f44c328eb020.verified.txt diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_7c4d5358dc16f63f.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_3243d3f3441fdcc1.verified.txt similarity index 100% rename from src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_7c4d5358dc16f63f.verified.txt rename to src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_3243d3f3441fdcc1.verified.txt diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_1a73d3cfd329f922.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_53350b8b47df2112.verified.txt similarity index 100% rename from src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_1a73d3cfd329f922.verified.txt rename to src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_53350b8b47df2112.verified.txt diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_09cf40a5c545de68.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_6584e0ec46b8a11d.verified.txt similarity index 100% rename from src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_09cf40a5c545de68.verified.txt rename to src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_6584e0ec46b8a11d.verified.txt diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_17721ef496526b3e.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_81cc88db3d4eecfb.verified.txt similarity index 90% rename from src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_17721ef496526b3e.verified.txt rename to src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_81cc88db3d4eecfb.verified.txt index 078169b766..be47d537b2 100644 --- a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_17721ef496526b3e.verified.txt +++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_81cc88db3d4eecfb.verified.txt @@ -15,8 +15,8 @@ Enabled: true, Path: /graphql, AllowIntrospection: true, - NestedMutationOptions: { - NestedCreateOptions: { + MultipleMutationOptions: { + MultipleCreateOptions: { Enabled: true } } diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_8459925dada37738.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_8ea187616dbb5577.verified.txt similarity index 100% rename from src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_8459925dada37738.verified.txt rename to src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_8ea187616dbb5577.verified.txt diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_9efd9a8a0ff47434.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_905845c29560a3ef.verified.txt similarity index 100% rename from src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_9efd9a8a0ff47434.verified.txt rename to src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_905845c29560a3ef.verified.txt diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_181195e2fbe991a8.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_b2fd24fab5b80917.verified.txt similarity index 100% rename from src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_181195e2fbe991a8.verified.txt rename to src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_b2fd24fab5b80917.verified.txt diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_388c095980b1b53f.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_bd7cd088755287c9.verified.txt similarity index 100% rename from src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_388c095980b1b53f.verified.txt rename to src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_bd7cd088755287c9.verified.txt diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_384e318d9ed21c9c.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_d2eccba2f836b380.verified.txt similarity index 100% rename from src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_384e318d9ed21c9c.verified.txt rename to src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_d2eccba2f836b380.verified.txt diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_d1e814ccd5d8b8e8.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_d463eed7fe5e4bbe.verified.txt similarity index 100% rename from src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_d1e814ccd5d8b8e8.verified.txt rename to src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_d463eed7fe5e4bbe.verified.txt diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_6fb51a691160163b.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_d5520dd5c33f7b8d.verified.txt similarity index 100% rename from src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_6fb51a691160163b.verified.txt rename to src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_d5520dd5c33f7b8d.verified.txt diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_c0ee0e6a86fa0b7e.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_eab4a6010e602b59.verified.txt similarity index 100% rename from src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_c0ee0e6a86fa0b7e.verified.txt rename to src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_eab4a6010e602b59.verified.txt diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_ef2f00a9e204e114.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_ecaa688829b4030e.verified.txt similarity index 100% rename from src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_ef2f00a9e204e114.verified.txt rename to src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_ecaa688829b4030e.verified.txt diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_35696f184b0ec6f0.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_35696f184b0ec6f0.verified.txt deleted file mode 100644 index 794686467c..0000000000 --- a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithNestedMutationOptions_35696f184b0ec6f0.verified.txt +++ /dev/null @@ -1,35 +0,0 @@ -{ - DataSource: { - DatabaseType: CosmosDB_PostgreSQL - }, - Runtime: { - Rest: { - Enabled: true, - Path: /rest-api, - RequestBodyStrict: true - }, - GraphQL: { - Enabled: true, - Path: /graphql, - AllowIntrospection: true, - NestedMutationOptions: { - NestedCreateOptions: { - Enabled: false - } - } - }, - Host: { - Cors: { - Origins: [ - http://localhost:3000, - http://nolocalhost:80 - ], - AllowCredentials: false - }, - Authentication: { - Provider: StaticWebApps - } - } - }, - Entities: [] -} \ No newline at end of file diff --git a/src/Cli/Commands/InitOptions.cs b/src/Cli/Commands/InitOptions.cs index a3d49d6bb1..67b8ef5b62 100644 --- a/src/Cli/Commands/InitOptions.cs +++ b/src/Cli/Commands/InitOptions.cs @@ -37,7 +37,7 @@ public InitOptions( CliBool restEnabled = CliBool.None, CliBool graphqlEnabled = CliBool.None, CliBool restRequestBodyStrict = CliBool.None, - CliBool nestedCreateOperationEnabled = CliBool.None, + CliBool multipleCreateOperationEnabled = CliBool.None, string? config = null) : base(config) { @@ -60,7 +60,7 @@ public InitOptions( RestEnabled = restEnabled; GraphQLEnabled = graphqlEnabled; RestRequestBodyStrict = restRequestBodyStrict; - NestedCreateOperationEnabled = nestedCreateOperationEnabled; + MultipleCreateOperationEnabled = multipleCreateOperationEnabled; } [Option("database-type", Required = true, HelpText = "Type of database to connect. Supported values: mssql, cosmosdb_nosql, cosmosdb_postgresql, mysql, postgresql, dwsql")] @@ -122,8 +122,8 @@ public InitOptions( [Option("rest.request-body-strict", Required = false, HelpText = "(Default: true) Allow extraneous fields in the request body for REST.")] public CliBool RestRequestBodyStrict { get; } - [Option("graphql.nested-create.enabled", Required = false, HelpText = "(Default: false) Enables nested create operation for GraphQL. Supported values: true, false.")] - public CliBool NestedCreateOperationEnabled { get; } + [Option("graphql.multiple-create.enabled", Required = false, HelpText = "(Default: false) Enables multiple create operation for GraphQL. Supported values: true, false.")] + public CliBool MultipleCreateOperationEnabled { get; } public void Handler(ILogger logger, FileSystemRuntimeConfigLoader loader, IFileSystem fileSystem) { diff --git a/src/Cli/ConfigGenerator.cs b/src/Cli/ConfigGenerator.cs index 4b819bf28d..8fe10e1749 100644 --- a/src/Cli/ConfigGenerator.cs +++ b/src/Cli/ConfigGenerator.cs @@ -113,25 +113,25 @@ public static bool TryCreateRuntimeConfig(InitOptions options, FileSystemRuntime return false; } - bool isNestedCreateEnabledForGraphQL; + bool isMultipleCreateEnabledForGraphQL; - // Nested mutation operations are applicable only for MSSQL database. When the option --graphql.nested-create.enabled is specified for other database types, + // Multiple mutation operations are applicable only for MSSQL database. When the option --graphql.multiple-create.enabled is specified for other database types, // a warning is logged. - // When nested mutation operations are extended for other database types, this option should be honored. + // When multiple mutation operations are extended for other database types, this option should be honored. // Tracked by issue #2001: https://github.com/Azure/data-api-builder/issues/2001. - if (dbType is not DatabaseType.MSSQL && options.NestedCreateOperationEnabled is not CliBool.None) + if (dbType is not DatabaseType.MSSQL && options.MultipleCreateOperationEnabled is not CliBool.None) { - _logger.LogWarning($"The option --graphql.nested-create.enabled is not supported for the {dbType.ToString()} database type and will not be honored."); + _logger.LogWarning($"The option --graphql.multiple-create.enabled is not supported for the {dbType.ToString()} database type and will not be honored."); } - NestedMutationOptions? nestedMutationOptions = null; + MultipleMutationOptions? multipleMutationOptions = null; - // Nested mutation operations are applicable only for MSSQL database. When the option --graphql.nested-create.enabled is specified for other database types, + // Multiple mutation operations are applicable only for MSSQL database. When the option --graphql.multiple-create.enabled is specified for other database types, // it is not honored. - if (dbType is DatabaseType.MSSQL && options.NestedCreateOperationEnabled is not CliBool.None) + if (dbType is DatabaseType.MSSQL && options.MultipleCreateOperationEnabled is not CliBool.None) { - isNestedCreateEnabledForGraphQL = IsNestedCreateOperationEnabled(options.NestedCreateOperationEnabled); - nestedMutationOptions = new(nestedCreateOptions: new NestedCreateOptions(enabled: isNestedCreateEnabledForGraphQL)); + isMultipleCreateEnabledForGraphQL = IsMultipleCreateOperationEnabled(options.MultipleCreateOperationEnabled); + multipleMutationOptions = new(multipleCreateOptions: new MultipleCreateOptions(enabled: isMultipleCreateEnabledForGraphQL)); } switch (dbType) @@ -253,7 +253,7 @@ public static bool TryCreateRuntimeConfig(InitOptions options, FileSystemRuntime DataSource: dataSource, Runtime: new( Rest: new(restEnabled, restPath ?? RestRuntimeOptions.DEFAULT_PATH, options.RestRequestBodyStrict is CliBool.False ? false : true), - GraphQL: new(Enabled: graphQLEnabled, Path: graphQLPath, NestedMutationOptions: nestedMutationOptions), + GraphQL: new(Enabled: graphQLEnabled, Path: graphQLPath, MultipleMutationOptions: multipleMutationOptions), Host: new( Cors: new(options.CorsOrigin?.ToArray() ?? Array.Empty()), Authentication: new( @@ -307,13 +307,13 @@ private static bool TryDetermineIfApiIsEnabled(bool apiDisabledOptionValue, CliB } /// - /// Helper method to determine if the nested create operation is enabled or not based on the inputs from dab init command. + /// Helper method to determine if the multiple create operation is enabled or not based on the inputs from dab init command. /// - /// Input value for --graphql.nested-create.enabled option of the init command + /// Input value for --graphql.multiple-create.enabled option of the init command /// True/False - private static bool IsNestedCreateOperationEnabled(CliBool nestedCreateEnabledOptionValue) + private static bool IsMultipleCreateOperationEnabled(CliBool multipleCreateEnabledOptionValue) { - return nestedCreateEnabledOptionValue is CliBool.True; + return multipleCreateEnabledOptionValue is CliBool.True; } /// diff --git a/src/Config/Converters/GraphQLRuntimeOptionsConverterFactory.cs b/src/Config/Converters/GraphQLRuntimeOptionsConverterFactory.cs index 0101cbba87..5f69531e9f 100644 --- a/src/Config/Converters/GraphQLRuntimeOptionsConverterFactory.cs +++ b/src/Config/Converters/GraphQLRuntimeOptionsConverterFactory.cs @@ -57,10 +57,10 @@ internal GraphQLRuntimeOptionsConverter(bool replaceEnvVar) if (reader.TokenType == JsonTokenType.StartObject) { - // Initialize with Nested Mutation operations disabled by default + // Initialize with Multiple Mutation operations disabled by default GraphQLRuntimeOptions graphQLRuntimeOptions = new(); - NestedMutationOptionsConverter nestedMutationOptionsConverter = options.GetConverter(typeof(NestedMutationOptions)) as NestedMutationOptionsConverter ?? - throw new JsonException("Failed to get nested mutation options converter"); + MultipleMutationOptionsConverter multipleMutationOptionsConverter = options.GetConverter(typeof(MultipleMutationOptions)) as MultipleMutationOptionsConverter ?? + throw new JsonException("Failed to get multiple mutation options converter"); while (reader.Read()) { @@ -121,8 +121,8 @@ internal GraphQLRuntimeOptionsConverter(bool replaceEnvVar) break; - case "nested-mutations": - graphQLRuntimeOptions = graphQLRuntimeOptions with { NestedMutationOptions = nestedMutationOptionsConverter.Read(ref reader, typeToConvert, options) }; + case "multiple-mutations": + graphQLRuntimeOptions = graphQLRuntimeOptions with { MultipleMutationOptions = multipleMutationOptionsConverter.Read(ref reader, typeToConvert, options) }; break; default: @@ -143,13 +143,13 @@ public override void Write(Utf8JsonWriter writer, GraphQLRuntimeOptions value, J writer.WriteString("path", value.Path); writer.WriteBoolean("allow-introspection", value.AllowIntrospection); - if (value.NestedMutationOptions is not null) + if (value.MultipleMutationOptions is not null) { - NestedMutationOptionsConverter nestedMutationOptionsConverter = options.GetConverter(typeof(NestedMutationOptions)) as NestedMutationOptionsConverter ?? - throw new JsonException("Failed to get nested mutation options converter"); + MultipleMutationOptionsConverter multipleMutationOptionsConverter = options.GetConverter(typeof(MultipleMutationOptions)) as MultipleMutationOptionsConverter ?? + throw new JsonException("Failed to get multiple mutation options converter"); - nestedMutationOptionsConverter.Write(writer, value.NestedMutationOptions, options); + multipleMutationOptionsConverter.Write(writer, value.MultipleMutationOptions, options); } writer.WriteEndObject(); diff --git a/src/Config/Converters/NestedCreateOptionsConverter.cs b/src/Config/Converters/MultipleCreateOptionsConverter.cs similarity index 76% rename from src/Config/Converters/NestedCreateOptionsConverter.cs rename to src/Config/Converters/MultipleCreateOptionsConverter.cs index 7e495ef303..5904b6b0c2 100644 --- a/src/Config/Converters/NestedCreateOptionsConverter.cs +++ b/src/Config/Converters/MultipleCreateOptionsConverter.cs @@ -8,12 +8,12 @@ namespace Azure.DataApiBuilder.Config.Converters { /// - /// Converter for the nested create operation options. + /// Converter for the multiple create operation options. /// - internal class NestedCreateOptionsConverter : JsonConverter + internal class MultipleCreateOptionsConverter : JsonConverter { /// - public override NestedCreateOptions? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + public override MultipleCreateOptions? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { if (reader.TokenType == JsonTokenType.Null) { @@ -22,7 +22,7 @@ internal class NestedCreateOptionsConverter : JsonConverter if (reader.TokenType is JsonTokenType.StartObject) { - NestedCreateOptions? nestedCreateOptions = null; + MultipleCreateOptions? multipleCreateOptions = null; while (reader.Read()) { if (reader.TokenType == JsonTokenType.EndObject) @@ -43,7 +43,7 @@ internal class NestedCreateOptionsConverter : JsonConverter reader.Read(); if (reader.TokenType is JsonTokenType.True || reader.TokenType is JsonTokenType.False) { - nestedCreateOptions = new(reader.GetBoolean()); + multipleCreateOptions = new(reader.GetBoolean()); } break; @@ -52,14 +52,14 @@ internal class NestedCreateOptionsConverter : JsonConverter } } - return nestedCreateOptions; + return multipleCreateOptions; } - throw new JsonException("Failed to read the GraphQL Nested Create options"); + throw new JsonException("Failed to read the GraphQL Multiple Create options"); } /// - public override void Write(Utf8JsonWriter writer, NestedCreateOptions? value, JsonSerializerOptions options) + public override void Write(Utf8JsonWriter writer, MultipleCreateOptions? value, JsonSerializerOptions options) { // If the value is null, it is not written to the config file. if (value is null) diff --git a/src/Config/Converters/NestedMutationOptionsConverter.cs b/src/Config/Converters/MultipleMutationOptionsConverter.cs similarity index 51% rename from src/Config/Converters/NestedMutationOptionsConverter.cs rename to src/Config/Converters/MultipleMutationOptionsConverter.cs index f121e070dd..fb943cad5b 100644 --- a/src/Config/Converters/NestedMutationOptionsConverter.cs +++ b/src/Config/Converters/MultipleMutationOptionsConverter.cs @@ -8,21 +8,21 @@ namespace Azure.DataApiBuilder.Config.Converters { /// - /// Converter for the nested mutation options. + /// Converter for the multiple mutation options. /// - internal class NestedMutationOptionsConverter : JsonConverter + internal class MultipleMutationOptionsConverter : JsonConverter { - private readonly NestedCreateOptionsConverter _nestedCreateOptionsConverter; + private readonly MultipleCreateOptionsConverter _multipleCreateOptionsConverter; - public NestedMutationOptionsConverter(JsonSerializerOptions options) + public MultipleMutationOptionsConverter(JsonSerializerOptions options) { - _nestedCreateOptionsConverter = options.GetConverter(typeof(NestedCreateOptions)) as NestedCreateOptionsConverter ?? - throw new JsonException("Failed to get nested create options converter"); + _multipleCreateOptionsConverter = options.GetConverter(typeof(MultipleCreateOptions)) as MultipleCreateOptionsConverter ?? + throw new JsonException("Failed to get multiple create options converter"); } /// - public override NestedMutationOptions? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + public override MultipleMutationOptions? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { if (reader.TokenType == JsonTokenType.Null) { @@ -31,7 +31,7 @@ public NestedMutationOptionsConverter(JsonSerializerOptions options) if (reader.TokenType is JsonTokenType.StartObject) { - NestedMutationOptions? nestedMutationOptions = null; + MultipleMutationOptions? multipleMutationOptions = null; while (reader.Read()) { @@ -45,10 +45,10 @@ public NestedMutationOptionsConverter(JsonSerializerOptions options) { case "create": reader.Read(); - NestedCreateOptions? nestedCreateOptions = _nestedCreateOptionsConverter.Read(ref reader, typeToConvert, options); - if (nestedCreateOptions is not null) + MultipleCreateOptions? multipleCreateOptions = _multipleCreateOptionsConverter.Read(ref reader, typeToConvert, options); + if (multipleCreateOptions is not null) { - nestedMutationOptions = new(nestedCreateOptions); + multipleMutationOptions = new(multipleCreateOptions); } break; @@ -58,28 +58,28 @@ public NestedMutationOptionsConverter(JsonSerializerOptions options) } } - return nestedMutationOptions; + return multipleMutationOptions; } - throw new JsonException("Failed to read the GraphQL Nested Mutation options"); + throw new JsonException("Failed to read the GraphQL Multiple Mutation options"); } /// - public override void Write(Utf8JsonWriter writer, NestedMutationOptions? value, JsonSerializerOptions options) + public override void Write(Utf8JsonWriter writer, MultipleMutationOptions? value, JsonSerializerOptions options) { - // If the nested mutation options is null, it is not written to the config file. + // If the multiple mutation options is null, it is not written to the config file. if (value is null) { return; } - writer.WritePropertyName("nested-mutations"); + writer.WritePropertyName("multiple-mutations"); writer.WriteStartObject(); - if (value.NestedCreateOptions is not null) + if (value.MultipleCreateOptions is not null) { - _nestedCreateOptionsConverter.Write(writer, value.NestedCreateOptions, options); + _multipleCreateOptionsConverter.Write(writer, value.MultipleCreateOptions, options); } writer.WriteEndObject(); diff --git a/src/Config/ObjectModel/GraphQLRuntimeOptions.cs b/src/Config/ObjectModel/GraphQLRuntimeOptions.cs index 9033d269e6..24d8533e43 100644 --- a/src/Config/ObjectModel/GraphQLRuntimeOptions.cs +++ b/src/Config/ObjectModel/GraphQLRuntimeOptions.cs @@ -6,7 +6,7 @@ namespace Azure.DataApiBuilder.Config.ObjectModel; public record GraphQLRuntimeOptions(bool Enabled = true, string Path = GraphQLRuntimeOptions.DEFAULT_PATH, bool AllowIntrospection = true, - NestedMutationOptions? NestedMutationOptions = null) + MultipleMutationOptions? MultipleMutationOptions = null) { public const string DEFAULT_PATH = "/graphql"; } diff --git a/src/Config/ObjectModel/MultipleCreateOptions.cs b/src/Config/ObjectModel/MultipleCreateOptions.cs new file mode 100644 index 0000000000..c4a566bf29 --- /dev/null +++ b/src/Config/ObjectModel/MultipleCreateOptions.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +namespace Azure.DataApiBuilder.Config.ObjectModel; + +/// +/// Options for multiple create operations. +/// +/// Indicates whether multiple create operation is enabled. +public class MultipleCreateOptions +{ + /// + /// Indicates whether multiple create operation is enabled. + /// + public bool Enabled; + + public MultipleCreateOptions(bool enabled) + { + Enabled = enabled; + } +}; + diff --git a/src/Config/ObjectModel/MultipleMutationOptions.cs b/src/Config/ObjectModel/MultipleMutationOptions.cs new file mode 100644 index 0000000000..360b52a1f3 --- /dev/null +++ b/src/Config/ObjectModel/MultipleMutationOptions.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.DataApiBuilder.Config.ObjectModel; + +/// +/// Class that holds the options for all multiple mutation operations. +/// +/// Options for multiple create operation. +public class MultipleMutationOptions +{ + // Options for multiple create operation. + public MultipleCreateOptions? MultipleCreateOptions; + + public MultipleMutationOptions(MultipleCreateOptions? multipleCreateOptions = null) + { + MultipleCreateOptions = multipleCreateOptions; + } + +} diff --git a/src/Config/ObjectModel/NestedCreateOptions.cs b/src/Config/ObjectModel/NestedCreateOptions.cs deleted file mode 100644 index 8439646766..0000000000 --- a/src/Config/ObjectModel/NestedCreateOptions.cs +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. -namespace Azure.DataApiBuilder.Config.ObjectModel; - -/// -/// Options for nested create operations. -/// -/// Indicates whether nested create operation is enabled. -public class NestedCreateOptions -{ - /// - /// Indicates whether nested create operation is enabled. - /// - public bool Enabled; - - public NestedCreateOptions(bool enabled) - { - Enabled = enabled; - } -}; - diff --git a/src/Config/ObjectModel/NestedMutationOptions.cs b/src/Config/ObjectModel/NestedMutationOptions.cs deleted file mode 100644 index 0cf6c05e3e..0000000000 --- a/src/Config/ObjectModel/NestedMutationOptions.cs +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -namespace Azure.DataApiBuilder.Config.ObjectModel; - -/// -/// Class that holds the options for all nested mutation operations. -/// -/// Options for nested create operation. -public class NestedMutationOptions -{ - // Options for nested create operation. - public NestedCreateOptions? NestedCreateOptions; - - public NestedMutationOptions(NestedCreateOptions? nestedCreateOptions = null) - { - NestedCreateOptions = nestedCreateOptions; - } - - /// - /// Helper function that checks if nested create operation is enabled. - /// - /// True/False depending on whether nested create operation is enabled/disabled. - public bool IsNestedCreateOperationEnabled() - { - return NestedCreateOptions is not null && NestedCreateOptions.Enabled; - } - -} diff --git a/src/Config/RuntimeConfigLoader.cs b/src/Config/RuntimeConfigLoader.cs index a597d94f8d..f3c34b661a 100644 --- a/src/Config/RuntimeConfigLoader.cs +++ b/src/Config/RuntimeConfigLoader.cs @@ -171,8 +171,8 @@ public static JsonSerializerOptions GetSerializationOptions( options.Converters.Add(new EntityActionConverterFactory()); options.Converters.Add(new DataSourceFilesConverter()); options.Converters.Add(new EntityCacheOptionsConverterFactory()); - options.Converters.Add(new NestedCreateOptionsConverter()); - options.Converters.Add(new NestedMutationOptionsConverter(options)); + options.Converters.Add(new MultipleCreateOptionsConverter()); + options.Converters.Add(new MultipleMutationOptionsConverter(options)); options.Converters.Add(new DataSourceConverterFactory(replaceEnvVar)); if (replaceEnvVar) diff --git a/src/Service.Tests/Configuration/ConfigurationTests.cs b/src/Service.Tests/Configuration/ConfigurationTests.cs index ddf4a782e4..e6e955a098 100644 --- a/src/Service.Tests/Configuration/ConfigurationTests.cs +++ b/src/Service.Tests/Configuration/ConfigurationTests.cs @@ -1661,49 +1661,49 @@ public async Task TestSPRestDefaultsForManuallyConstructedConfigs( /// /// Validates that deserialization of config file is successful for the following scenarios: - /// 1. Nested Mutations section is null + /// 1. Multiple Mutations section is null /// { - /// "nested-mutations": null + /// "multiple-mutations": null /// } /// - /// 2. Nested Mutations section is empty. + /// 2. Multiple Mutations section is empty. /// { - /// "nested-mutations": {} + /// "multiple-mutations": {} /// } /// - /// 3. Create field within Nested Mutation section is null. + /// 3. Create field within Multiple Mutation section is null. /// { - /// "nested-mutations": { + /// "multiple-mutations": { /// "create": null /// } /// } /// - /// 4. Create field within Nested Mutation section is empty. + /// 4. Create field within Multiple Mutation section is empty. /// { - /// "nested-mutations": { + /// "multiple-mutations": { /// "create": {} /// } /// } /// - /// For all the above mentioned scenarios, the expected value for NestedMutationOptions field is null. + /// For all the above mentioned scenarios, the expected value for MultipleMutationOptions field is null. /// /// Base Config Json string. [DataTestMethod] - [DataRow(TestHelper.BASE_CONFIG_NULL_NESTED_MUTATIONS_FIELD, DisplayName = "NestedMutationOptions field deserialized as null when nested mutation section is null")] - [DataRow(TestHelper.BASE_CONFIG_EMPTY_NESTED_MUTATIONS_FIELD, DisplayName = "NestedMutationOptions field deserialized as null when nested mutation section is empty")] - [DataRow(TestHelper.BASE_CONFIG_NULL_NESTED_CREATE_FIELD, DisplayName = "NestedMutationOptions field deserialized as null when create field within nested mutation section is null")] - [DataRow(TestHelper.BASE_CONFIG_EMPTY_NESTED_CREATE_FIELD, DisplayName = "NestedMutationOptions field deserialized as null when create field within nested mutation section is empty")] - public void ValidateDeserializationOfConfigWithNullOrEmptyInvalidNestedMutationSection(string baseConfig) + [DataRow(TestHelper.BASE_CONFIG_NULL_MULTIPLE_MUTATIONS_FIELD, DisplayName = "MultipleMutationOptions field deserialized as null when multiple mutation section is null")] + [DataRow(TestHelper.BASE_CONFIG_EMPTY_MULTIPLE_MUTATIONS_FIELD, DisplayName = "MultipleMutationOptions field deserialized as null when multiple mutation section is empty")] + [DataRow(TestHelper.BASE_CONFIG_NULL_MULTIPLE_CREATE_FIELD, DisplayName = "MultipleMutationOptions field deserialized as null when create field within multiple mutation section is null")] + [DataRow(TestHelper.BASE_CONFIG_EMPTY_MULTIPLE_CREATE_FIELD, DisplayName = "MultipleMutationOptions field deserialized as null when create field within multiple mutation section is empty")] + public void ValidateDeserializationOfConfigWithNullOrEmptyInvalidMultipleMutationSection(string baseConfig) { string configJson = TestHelper.AddPropertiesToJson(baseConfig, BOOK_ENTITY_JSON); Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(configJson, out RuntimeConfig deserializedConfig)); Assert.IsNotNull(deserializedConfig.Runtime); Assert.IsNotNull(deserializedConfig.Runtime.GraphQL); - Assert.IsNull(deserializedConfig.Runtime.GraphQL.NestedMutationOptions); + Assert.IsNull(deserializedConfig.Runtime.GraphQL.MultipleMutationOptions); } /// - /// Sanity check to validate that DAB engine starts successfully when used with a config file without the nested + /// Sanity check to validate that DAB engine starts successfully when used with a config file without the multiple /// mutations feature flag section. /// The runtime graphql section of the config file used looks like this: /// @@ -1712,14 +1712,14 @@ public void ValidateDeserializationOfConfigWithNullOrEmptyInvalidNestedMutationS /// "allow-introspection": true /// } /// - /// Without the nested mutations feature flag section, DAB engine should be able to - /// 1. Successfully deserialize the config file without nested mutation section. + /// Without the multiple mutations feature flag section, DAB engine should be able to + /// 1. Successfully deserialize the config file without multiple mutation section. /// 2. Process REST and GraphQL API requests. /// /// [TestMethod] [TestCategory(TestCategory.MSSQL)] - public async Task SanityTestForRestAndGQLRequestsWithoutNestedMutationFeatureFlagSection() + public async Task SanityTestForRestAndGQLRequestsWithoutMultipleMutationFeatureFlagSection() { // The configuration file is constructed by merging hard-coded JSON strings to simulate the scenario where users manually edit the // configuration file (instead of using CLI). diff --git a/src/Service.Tests/Multidab-config.MsSql.json b/src/Service.Tests/Multidab-config.MsSql.json index 9f05161a83..a428c0a27b 100644 --- a/src/Service.Tests/Multidab-config.MsSql.json +++ b/src/Service.Tests/Multidab-config.MsSql.json @@ -16,7 +16,7 @@ "enabled": true, "path": "/graphql", "allow-introspection": true, - "nested-mutations": { + "multiple-mutations": { "create": { "enabled": true } diff --git a/src/Service.Tests/TestHelper.cs b/src/Service.Tests/TestHelper.cs index 466ca311ef..a8d152651c 100644 --- a/src/Service.Tests/TestHelper.cs +++ b/src/Service.Tests/TestHelper.cs @@ -237,47 +237,47 @@ public static RuntimeConfig AddMissingEntitiesToConfig(RuntimeConfig config, str "}"; /// - /// A minimal valid config json with nested mutations section as null. + /// A minimal valid config json with multiple mutations section as null. /// - public const string BASE_CONFIG_NULL_NESTED_MUTATIONS_FIELD = + public const string BASE_CONFIG_NULL_MULTIPLE_MUTATIONS_FIELD = RUNTIME_REST_GRAPHQL_OPTIONS_CONFIG_JSON + @" - ""nested-mutations"": null + ""multiple-mutations"": null }," + HOST_AND_ENTITY_OPTIONS_CONFIG_JSON; /// - /// A minimal valid config json with an empty nested mutations section. + /// A minimal valid config json with an empty multiple mutations section. /// - public const string BASE_CONFIG_EMPTY_NESTED_MUTATIONS_FIELD = + public const string BASE_CONFIG_EMPTY_MULTIPLE_MUTATIONS_FIELD = RUNTIME_REST_GRAPHQL_OPTIONS_CONFIG_JSON + @" - ""nested-mutations"": {} + ""multiple-mutations"": {} }," + HOST_AND_ENTITY_OPTIONS_CONFIG_JSON; /// - /// A minimal valid config json with the create field within nested mutation as null. + /// A minimal valid config json with the create field within multiple mutation as null. /// - public const string BASE_CONFIG_NULL_NESTED_CREATE_FIELD = + public const string BASE_CONFIG_NULL_MULTIPLE_CREATE_FIELD = RUNTIME_REST_GRAPHQL_OPTIONS_CONFIG_JSON + @" - ""nested-mutations"": { + ""multiple-mutations"": { ""create"": null } }," + HOST_AND_ENTITY_OPTIONS_CONFIG_JSON; /// - /// A minimal valid config json with an empty create field within nested mutation. + /// A minimal valid config json with an empty create field within multiple mutation. /// - public const string BASE_CONFIG_EMPTY_NESTED_CREATE_FIELD = + public const string BASE_CONFIG_EMPTY_MULTIPLE_CREATE_FIELD = RUNTIME_REST_GRAPHQL_OPTIONS_CONFIG_JSON + @" - ""nested-mutations"": { + ""multiple-mutations"": { ""create"": {} } }," + diff --git a/src/Service.Tests/Unittests/RuntimeConfigLoaderJsonDeserializerTests.cs b/src/Service.Tests/Unittests/RuntimeConfigLoaderJsonDeserializerTests.cs index 2f950b20d0..0d754263bb 100644 --- a/src/Service.Tests/Unittests/RuntimeConfigLoaderJsonDeserializerTests.cs +++ b/src/Service.Tests/Unittests/RuntimeConfigLoaderJsonDeserializerTests.cs @@ -423,7 +423,7 @@ public static string GetModifiedJsonString(string[] reps, string enumString) ""enabled"": true, ""path"": """ + reps[++index % reps.Length] + @""", ""allow-introspection"": true, - ""nested-mutations"": { + ""multiple-mutations"": { ""create"": { ""enabled"": false } diff --git a/src/Service.Tests/dab-config.MsSql.json b/src/Service.Tests/dab-config.MsSql.json index 0453d1ecf1..be8a96d2c3 100644 --- a/src/Service.Tests/dab-config.MsSql.json +++ b/src/Service.Tests/dab-config.MsSql.json @@ -17,7 +17,7 @@ "enabled": true, "path": "/graphql", "allow-introspection": true, - "nested-mutations": { + "multiple-mutations": { "create": { "enabled": true } From e2f9418b9631c2eeff00e39ce71477aea8552c6e Mon Sep 17 00:00:00 2001 From: Ayush Agarwal <34566234+ayush3797@users.noreply.github.com> Date: Thu, 21 Mar 2024 19:56:23 +0530 Subject: [PATCH 5/8] Multiple-create: Schema generation (#1902) ## Why make this change? Currently, for a create mutation (or any graphql mutation essentially), we can only do insertion/mutation in one table and not in another table which is related to this table via some relationship (either defined in config or in the database). This PR aims to: 1. Extend the functionality of the existing create mutations (which are basically point insertions) to allow insertions in related table. 2. To generate additional `createMultiple` mutations which will allow multiple nested insertions starting from the top-level entity. **Note:** _All changes made in this PR are specific to MsSql. Nothing changes for the other database flavors._ ## Quick glossary: 1. **ObjectTypeDefinitionNode:** Represents a table/view/stored-procedure. For a table, this contains all the column fields which belong to the table and the relationship fields which are defined in this table's entity configuration in the config file. 2. **InputObjectTypeDefinitionNode:** Represents an input object type we generate for mutations. ## What is this change? 1. **We no more ignore relationship fields while generating the input types for a create mutation**: Till date, we were only considering the column fields in an object (of type `ObjectTypeDefinitionNode`) to generate the input type (of type `InputObjectTypeDefinitionNode`) for a table entity. But to support nested insertion, we would now also iterate over relationship fields to generate the input type for a **create**/**createMultiple** mutation. 2. **Addition of linking entities at the backend:** To support nested insertions in tables which have a relationship with cardinality N:N, the user can provide a linking table with the source defined in `linking.object` field which we need the user to provide. We need to generate an object type (of type `ObjectTypeDefinitionNode`) for this linking entity. This object type is later be used to generate the input object type for the linked tables. Additional details about linking entities: -> **A boolean property `IsLinkingEntity` is added with a _default value of `false`_** to the `Entity` record. This ensures that we are backwards compatible, _and all the entities provided in the config are not considered as linking entities. For all the linking entities, this boolean property will be set to `true`._ -> For a linking entity, **_the GraphQL and REST endpoints are disabled (set to `false`) by default_**. This ensures that we don't accidentally expose the linking entity to the user. -> **MsSqlMetadataProvider.EntityToDatabaseObject contains database objects for Entities in config + Linking Entities**. After we get all the deserialized entities, we will create entities to represent linking tables. The database objects for all the entities in config will be generated by a call to **SqlMetadataProvider.GenerateDatabaseObjectForEntities()** method which in turn sequentially goes over all the entities in the config and call the method **SqlMetadataProvider.PopulateDatabaseObjectForEntity()** which actually populates the database object for an entity. -> The method **SqlMetadataProvider.AddForeignKeysForRelationships()** is renamed to **SqlMetadataProvider.ProcessRelationships()**. The method now in addition to adding FKs to metadata, also creates linking entities for M:N relationships. This is done via a call to method **SqlMetadataProvider.PopulateMetadataForLinkingObject()**. -> The method **PopulateMetadataForLinkingObject()** is a virtual method which has an overridden implementation only for MsSql. Thus, linking entities are generated only for MsSql. 3. **ReferencingFieldDirectiveType:** is a new directive type being added. The presence of this directive for a column field implies that the column is a referencing key to some column in another entity exposed in the config. With nested insertions support, the values for such fields can come via insertion in the referenced table. _And hence while generating the input object for a `create`/`createMultiple` mutation, we mark such a field as not required_. 4. **Name of the create multiple mutation:** is generated via a call to the newly added helper method `CreateMutationBuilder.GetInsertMultipleMutationName(singularName, pluralName)` which takes in the singular/plural names of the entity. If singularName == pluralName, the createMultiple mutation will be generated with the name: `create{singularName}_Multiple` else, it will be generated with the name `create{pluralName}`. The pluralName for an entity is fetched using another newly added helper method `GraphQLNaming.GetDefinedPluralName(entityName, configEntity)` 5. **For create multiple mutations, the input argument name will be `items`** which is stored in a constant string `MutationBuilder.ARRAY_INPUT_ARGUMENT_NAME`. 6. **Object types (of type `ObjectTypeDefinitionNode`) for linking entities** will be generated using a method `GraphQLSchemaCreator.GenerateObjectDefinitionsForLinkingEntities(linkingEntityNames, entities)`. 7. **sourceTargetLinkingNode:** The object type for linking entity is later used to generate object type for a `sourceTargetLinkingNode` which will contain: -> Fields present in the linking table (but not used to relate source/target) -> All the fields from the target entity. This is done using a call to the newly added method `GraphQLSchemaCreator.GenerateSourceTargetLinkingObjectDefinitions()`. 8. Renamed method `GraphQLSchemaCreator.FromDatabaseObject()` to `GraphQLSchemaCreator.GenerateObjectTypeDefinitionForDatabaseObject()` to better convey what the method is doing. 9. To make code more streamlined and bug-free the method **SchemaConverter.FromDatabaseObject()** is broken down into smaller chunks which handle smaller and easy to read/comprehend responsibilities. Passing of existing test cases confirm the refactor. 10. Added a method `GraphQLUtils.GenerateLinkingEntityName(sourceEntityName, targetEntityName)` which concatenates the names of the source and target entities with a delimiter (`ENTITY_NAME_DELIMITER = "$"`) between the names and prefixes the concatenated string with: `LINKING_ENTITY_PREFIX = "LinkingEntity"` string. 11. Added a method `GraphQLUtils.GetSourceAndTargetEntityNameFromLinkingEntityName(string linkingEntityName)` which returns the the names of the source and target entities from the linking entity name. 12. Split the `CreateMutationBuilder.GenerateCreateInputType()` method which was used to create input types for Relational (Pg/My/Ms/Dw Sql) and non-relational dbs (Cosmos_NoSql) into two new methods: `CreateMutationBuilder.GenerateCreateInputTypeForRelationalDb()` and `CreateMutationBuilder.GenerateCreateInputTypeForNonRelationalDb()`. 13. Split the `CreateMutationBuilder.GetComplexInputType()` method which was used to build input types for Relational (Pg/My/Ms/Dw Sql) and non-relational dbs (Cosmos_NoSql) into two new methods: `CreateMutationBuilder.GenerateComplexInputTypeForRelationalDb()` and `CreateMutationBuilder.GenerateComplexInputTypeForNonRelationalDb()`. ## Validations Required: 1. **Ensure non-conflicting names of fields in sourceTargetLinkingNode for N:N relationships:** Object types are generated for `sourceTargetLinkingNode` for a source, target pair of tables (refer above for what all fields would be present in this object type). The name of the field (either a relationship field or a column field) in the target entity might conflict with the name of the column field in the linking table. Hence, we need a validation in place to ensure that if there is a conflict, we catch it during startup (currently this check is done during schema generation) and throw an appropriate exception with an actionable message. 2. **Ensure non-conflicting names of relationship fields in column fields in one entity:** This is a validation which is required in the current architecture as well and is a bug (see here: https://github.com/Azure/data-api-builder/issues/1937). This is required to ensure that relationship field names don't conflict with the exposed names of column fields in a table. 3. **Ensure we have values for FK referencing fields to perform insertions:** All the fields in a table which hold a foreign key reference to some other column in another entity are now nullable in the generate input objects for create mutation. This is because we assume that the value for such a field might come via insertion in the related entity/table. However, if the create mutation does not include a nested insert, the value of such a field still has to be specified by the user (unless the field is nullable/has default at the database level as well in which case we can give a null value/default value for the field- although this is highly unlikely since the field is a foreign key). If we don't get a value for such a FK referencing field: -> Either via nested insertion , or -> By the user we should throw an exception accordingly. This will maintain backwards compatibility. **UNLESS THIS VALIDATION IS IN, WE ARE NOT BACKWARDS COMPATIBLE.** 4. **Ensure there is only one source of value for FK referencing fields:** If the user is providing a value for an FK referencing field, and there is a nested insertion involved which also returns a value for the same field, we should throw an exception as there are two conflicting sources of truth. **In short, exactly one of the sources should provide a value.** ## How was this tested? - [x] Unit Tests - Added to `NestedMutationBuilderTests.cs` class. ## Sample Request(s) 1. image --- src/Cli.Tests/ModuleInitializer.cs | 2 + src/Config/ObjectModel/Entity.cs | 7 +- src/Core/Parsers/EdmModelBuilder.cs | 23 +- src/Core/Resolvers/CosmosMutationEngine.cs | 6 +- .../SqlInsertQueryStructure.cs | 2 +- src/Core/Resolvers/SqlMutationEngine.cs | 2 +- src/Core/Services/GraphQLSchemaCreator.cs | 293 ++++++++++- .../MetadataProviders/ISqlMetadataProvider.cs | 5 + .../MsSqlMetadataProvider.cs | 34 ++ .../MetadataProviders/SqlMetadataProvider.cs | 234 +++++---- .../Services/OpenAPI/OpenApiDocumentor.cs | 16 +- src/Core/Services/RequestValidator.cs | 3 +- .../ReferencingFieldDirectiveType.cs | 21 + src/Service.GraphQLBuilder/GraphQLNaming.cs | 27 +- src/Service.GraphQLBuilder/GraphQLUtils.cs | 60 +++ .../Mutations/CreateMutationBuilder.cs | 463 +++++++++++++++--- .../Mutations/MutationBuilder.cs | 15 +- .../Queries/QueryBuilder.cs | 6 +- .../Sql/SchemaConverter.cs | 324 ++++++++---- .../GraphQLMutationAuthorizationTests.cs | 2 +- src/Service.Tests/DatabaseSchema-MsSql.sql | 1 + .../MsSqlMultipleMutationBuilderTests.cs | 19 + .../MultipleMutationBuilderTests.cs | 429 ++++++++++++++++ .../GraphQLBuilder/MutationBuilderTests.cs | 64 +-- .../Sql/SchemaConverterTests.cs | 36 +- .../Sql/StoredProcedureBuilderTests.cs | 2 +- src/Service.Tests/ModuleInitializer.cs | 2 + 27 files changed, 1742 insertions(+), 356 deletions(-) create mode 100644 src/Service.GraphQLBuilder/Directives/ReferencingFieldDirectiveType.cs create mode 100644 src/Service.Tests/GraphQLBuilder/MsSqlMultipleMutationBuilderTests.cs create mode 100644 src/Service.Tests/GraphQLBuilder/MultipleMutationBuilderTests.cs diff --git a/src/Cli.Tests/ModuleInitializer.cs b/src/Cli.Tests/ModuleInitializer.cs index bcee0a6993..73f31a3158 100644 --- a/src/Cli.Tests/ModuleInitializer.cs +++ b/src/Cli.Tests/ModuleInitializer.cs @@ -31,6 +31,8 @@ public static void Init() VerifierSettings.IgnoreMember(options => options.IsCachingEnabled); // Ignore the entity IsCachingEnabled as that's unimportant from a test standpoint. VerifierSettings.IgnoreMember(entity => entity.IsCachingEnabled); + // Ignore the entity IsLinkingEntity as that's unimportant from a test standpoint. + VerifierSettings.IgnoreMember(entity => entity.IsLinkingEntity); // Ignore the UserProvidedTtlOptions. They aren't serialized to our config file, enforced by EntityCacheOptionsConverter. VerifierSettings.IgnoreMember(cacheOptions => cacheOptions.UserProvidedTtlOptions); // Ignore the IsRequestBodyStrict as that's unimportant from a test standpoint. diff --git a/src/Config/ObjectModel/Entity.cs b/src/Config/ObjectModel/Entity.cs index e3975f7ee6..ec92a1f5a4 100644 --- a/src/Config/ObjectModel/Entity.cs +++ b/src/Config/ObjectModel/Entity.cs @@ -32,6 +32,9 @@ public record Entity public Dictionary? Relationships { get; init; } public EntityCacheOptions? Cache { get; init; } + [JsonIgnore] + public bool IsLinkingEntity { get; init; } + [JsonConstructor] public Entity( EntitySource Source, @@ -40,7 +43,8 @@ public Entity( EntityPermission[] Permissions, Dictionary? Mappings, Dictionary? Relationships, - EntityCacheOptions? Cache = null) + EntityCacheOptions? Cache = null, + bool IsLinkingEntity = false) { this.Source = Source; this.GraphQL = GraphQL; @@ -49,6 +53,7 @@ public Entity( this.Mappings = Mappings; this.Relationships = Relationships; this.Cache = Cache; + this.IsLinkingEntity = IsLinkingEntity; } /// diff --git a/src/Core/Parsers/EdmModelBuilder.cs b/src/Core/Parsers/EdmModelBuilder.cs index d895c8391b..8180e58db0 100644 --- a/src/Core/Parsers/EdmModelBuilder.cs +++ b/src/Core/Parsers/EdmModelBuilder.cs @@ -47,8 +47,16 @@ private EdmModelBuilder BuildEntityTypes(ISqlMetadataProvider sqlMetadataProvide // since we allow for aliases to be used in place of the names of the actual // columns of the database object (such as table's columns), we need to // account for these potential aliases in our EDM Model. + IReadOnlyDictionary linkingEntities = sqlMetadataProvider.GetLinkingEntities(); foreach (KeyValuePair entityAndDbObject in sqlMetadataProvider.GetEntityNamesAndDbObjects()) { + if (linkingEntities.ContainsKey(entityAndDbObject.Key)) + { + // No need to create entity types for linking entity because the linking entity is not exposed for REST and GraphQL. + // Hence, there is no possibility of having a `filter` operation against it. + continue; + } + // Do not add stored procedures, which do not have table definitions or conventional columns, to edm model // As of now, no ODataFilterParsing will be supported for stored procedure result sets if (entityAndDbObject.Value.SourceType is not EntitySourceType.StoredProcedure) @@ -109,12 +117,19 @@ private EdmModelBuilder BuildEntitySets(ISqlMetadataProvider sqlMetadataProvider // Entity set is a collection of the same entity, if we think of an entity as a row of data // that has a key, then an entity set can be thought of as a table made up of those rows. - foreach (KeyValuePair entityAndDbObject in sqlMetadataProvider.GetEntityNamesAndDbObjects()) + IReadOnlyDictionary linkingEntities = sqlMetadataProvider.GetLinkingEntities(); + foreach ((string entityName, DatabaseObject dbObject) in sqlMetadataProvider.GetEntityNamesAndDbObjects()) { - if (entityAndDbObject.Value.SourceType != EntitySourceType.StoredProcedure) + if (linkingEntities.ContainsKey(entityName)) + { + // No need to create entity set for linking entity. + continue; + } + + if (dbObject.SourceType != EntitySourceType.StoredProcedure) { - string entityName = $"{entityAndDbObject.Value.FullName}"; - container.AddEntitySet(name: $"{entityAndDbObject.Key}.{entityName}", _entities[$"{entityAndDbObject.Key}.{entityName}"]); + string fullSourceName = $"{dbObject.FullName}"; + container.AddEntitySet(name: $"{entityName}.{fullSourceName}", _entities[$"{entityName}.{fullSourceName}"]); } } diff --git a/src/Core/Resolvers/CosmosMutationEngine.cs b/src/Core/Resolvers/CosmosMutationEngine.cs index 1cd4650118..4f95e4f267 100644 --- a/src/Core/Resolvers/CosmosMutationEngine.cs +++ b/src/Core/Resolvers/CosmosMutationEngine.cs @@ -104,7 +104,7 @@ public void AuthorizeMutationFields( List inputArgumentKeys; if (mutationOperation != EntityActionOperation.Delete) { - inputArgumentKeys = BaseSqlQueryStructure.GetSubArgumentNamesFromGQLMutArguments(MutationBuilder.INPUT_ARGUMENT_NAME, parameters); + inputArgumentKeys = BaseSqlQueryStructure.GetSubArgumentNamesFromGQLMutArguments(MutationBuilder.ITEM_INPUT_ARGUMENT_NAME, parameters); } else { @@ -165,7 +165,7 @@ private static async Task> HandleDeleteAsync(IDictionary> HandleCreateAsync(IDictionary queryArgs, Container container) { - object? item = queryArgs[CreateMutationBuilder.INPUT_ARGUMENT_NAME]; + object? item = queryArgs[MutationBuilder.ITEM_INPUT_ARGUMENT_NAME]; JObject? input; // Variables were provided to the mutation @@ -212,7 +212,7 @@ private static async Task> HandleUpdateAsync(IDictionary inputArgumentKeys; if (mutationOperation != EntityActionOperation.Delete) { - inputArgumentKeys = BaseSqlQueryStructure.GetSubArgumentNamesFromGQLMutArguments(MutationBuilder.INPUT_ARGUMENT_NAME, parameters); + inputArgumentKeys = BaseSqlQueryStructure.GetSubArgumentNamesFromGQLMutArguments(MutationBuilder.ITEM_INPUT_ARGUMENT_NAME, parameters); } else { diff --git a/src/Core/Services/GraphQLSchemaCreator.cs b/src/Core/Services/GraphQLSchemaCreator.cs index 9829dbf0f8..dfe1a1b0a6 100644 --- a/src/Core/Services/GraphQLSchemaCreator.cs +++ b/src/Core/Services/GraphQLSchemaCreator.cs @@ -20,6 +20,7 @@ using Azure.DataApiBuilder.Service.Services; using HotChocolate.Language; using Microsoft.Extensions.DependencyInjection; +using static Azure.DataApiBuilder.Service.GraphQLBuilder.GraphQLNaming; namespace Azure.DataApiBuilder.Core.Services { @@ -90,6 +91,7 @@ private ISchemaBuilder Parse( .AddDirectiveType() .AddDirectiveType() .AddDirectiveType() + .AddDirectiveType() .AddDirectiveType() .AddDirectiveType() // Add our custom scalar GraphQL types @@ -162,11 +164,18 @@ public ISchemaBuilder InitializeSchemaAndResolvers(ISchemaBuilder schemaBuilder) /// private DocumentNode GenerateSqlGraphQLObjects(RuntimeEntities entities, Dictionary inputObjects) { + // Dictionary to store: + // 1. Object types for every entity exposed for MySql/PgSql/MsSql/DwSql in the config file. + // 2. Object type for source->target linking object for M:N relationships to support insertion in the target table, + // followed by an insertion in the linking table. The directional linking object contains all the fields from the target entity + // (relationship/column) and non-relationship fields from the linking table. Dictionary objectTypes = new(); - // First pass - build up the object and input types for all the entities + // 1. Build up the object and input types for all the exposed entities in the config. foreach ((string entityName, Entity entity) in entities) { + string dataSourceName = _runtimeConfigProvider.GetConfig().GetDataSourceNameFromEntityName(entityName); + ISqlMetadataProvider sqlMetadataProvider = _metadataProviderFactory.GetMetadataProvider(dataSourceName); // Skip creating the GraphQL object for the current entity due to configuration // explicitly excluding the entity from the GraphQL endpoint. if (!entity.GraphQL.Enabled) @@ -174,9 +183,6 @@ private DocumentNode GenerateSqlGraphQLObjects(RuntimeEntities entities, Diction continue; } - string dataSourceName = _runtimeConfigProvider.GetConfig().GetDataSourceNameFromEntityName(entityName); - ISqlMetadataProvider sqlMetadataProvider = _metadataProviderFactory.GetMetadataProvider(dataSourceName); - if (sqlMetadataProvider.GetEntityNamesAndDbObjects().TryGetValue(entityName, out DatabaseObject? databaseObject)) { // Collection of role names allowed to access entity, to be added to the authorize directive @@ -203,14 +209,14 @@ private DocumentNode GenerateSqlGraphQLObjects(RuntimeEntities entities, Diction // Only add objectTypeDefinition for GraphQL if it has a role definition defined for access. if (rolesAllowedForEntity.Any()) { - ObjectTypeDefinitionNode node = SchemaConverter.FromDatabaseObject( - entityName, - databaseObject, - entity, - entities, - rolesAllowedForEntity, - rolesAllowedForFields - ); + ObjectTypeDefinitionNode node = SchemaConverter.GenerateObjectTypeDefinitionForDatabaseObject( + entityName: entityName, + databaseObject: databaseObject, + configEntity: entity, + entities: entities, + rolesAllowedForEntity: rolesAllowedForEntity, + rolesAllowedForFields: rolesAllowedForFields + ); if (databaseObject.SourceType is not EntitySourceType.StoredProcedure) { @@ -228,12 +234,21 @@ private DocumentNode GenerateSqlGraphQLObjects(RuntimeEntities entities, Diction } } + // For all the fields in the object which hold a foreign key reference to any referenced entity, add a foreign key directive. + AddReferencingFieldDirective(entities, objectTypes); + // Pass two - Add the arguments to the many-to-* relationship fields foreach ((string entityName, ObjectTypeDefinitionNode node) in objectTypes) { objectTypes[entityName] = QueryBuilder.AddQueryArgumentsForRelationships(node, inputObjects); } + // Create ObjectTypeDefinitionNode for linking entities. These object definitions are not exposed in the schema + // but are used to generate the object definitions of directional linking entities for (source, target) and (target, source) entities. + Dictionary linkingObjectTypes = GenerateObjectDefinitionsForLinkingEntities(); + GenerateSourceTargetLinkingObjectDefinitions(objectTypes, linkingObjectTypes); + + // Return a list of all the object types to be exposed in the schema. Dictionary fields = new(); // Add the DBOperationResult type to the schema @@ -254,6 +269,260 @@ private DocumentNode GenerateSqlGraphQLObjects(RuntimeEntities entities, Diction return new DocumentNode(nodes); } + /// + /// Helper method to traverse through all the relationships for all the entities exposed in the config. + /// For all the relationships defined in each entity's configuration, it adds a referencing field directive to all the + /// referencing fields of the referencing entity in the relationship. For relationships defined in config: + /// 1. If an FK constraint exists between the entities - the referencing field directive + /// is added to the referencing fields from the referencing entity. + /// 2. If no FK constraint exists between the entities - the referencing field directive + /// is added to the source.fields/target.fields from both the source and target entities. + /// + /// The values of such fields holding foreign key references can come via insertions in the related entity. + /// By adding ForiegnKeyDirective here, we can later ensure that while creating input type for create mutations, + /// these fields can be marked as nullable/optional. + /// + /// Collection of object types. + /// Entities from runtime config. + private void AddReferencingFieldDirective(RuntimeEntities entities, Dictionary objectTypes) + { + foreach ((string sourceEntityName, ObjectTypeDefinitionNode sourceObjectTypeDefinitionNode) in objectTypes) + { + if (!entities.TryGetValue(sourceEntityName, out Entity? entity)) + { + continue; + } + + if (!entity.GraphQL.Enabled || entity.Source.Type is not EntitySourceType.Table || entity.Relationships is null) + { + // Multiple create is only supported on database tables for which GraphQL endpoint is enabled. + continue; + } + + string dataSourceName = _runtimeConfigProvider.GetConfig().GetDataSourceNameFromEntityName(sourceEntityName); + ISqlMetadataProvider sqlMetadataProvider = _metadataProviderFactory.GetMetadataProvider(dataSourceName); + SourceDefinition sourceDefinition = sqlMetadataProvider.GetSourceDefinition(sourceEntityName); + Dictionary sourceFieldDefinitions = sourceObjectTypeDefinitionNode.Fields.ToDictionary(field => field.Name.Value, field => field); + + // Retrieve all the relationship information for the source entity which is backed by this table definition. + sourceDefinition.SourceEntityRelationshipMap.TryGetValue(sourceEntityName, out RelationshipMetadata? relationshipInfo); + + // Retrieve the database object definition for the source entity. + sqlMetadataProvider.GetEntityNamesAndDbObjects().TryGetValue(sourceEntityName, out DatabaseObject? sourceDbo); + foreach ((_, EntityRelationship relationship) in entity.Relationships) + { + string targetEntityName = relationship.TargetEntity; + if (!string.IsNullOrEmpty(relationship.LinkingObject)) + { + // The presence of LinkingObject indicates that the relationship is a M:N relationship. For M:N relationships, + // the fields in this entity are referenced fields and the fields in the linking table are referencing fields. + // Thus, it is not required to add the directive to any field in this entity. + continue; + } + + // From the relationship information, obtain the foreign key definition for the given target entity and add the + // referencing field directive to the referencing fields from the referencing table (whether it is the source entity or the target entity). + if (relationshipInfo is not null && + relationshipInfo.TargetEntityToFkDefinitionMap.TryGetValue(targetEntityName, out List? listOfForeignKeys)) + { + // Find the foreignkeys in which the source entity is the referencing object. + IEnumerable sourceReferencingForeignKeysInfo = + listOfForeignKeys.Where(fk => + fk.ReferencingColumns.Count > 0 + && fk.ReferencedColumns.Count > 0 + && fk.Pair.ReferencingDbTable.Equals(sourceDbo)); + + sqlMetadataProvider.GetEntityNamesAndDbObjects().TryGetValue(targetEntityName, out DatabaseObject? targetDbo); + // Find the foreignkeys in which the target entity is the referencing object, i.e. source entity is the referenced object. + IEnumerable targetReferencingForeignKeysInfo = + listOfForeignKeys.Where(fk => + fk.ReferencingColumns.Count > 0 + && fk.ReferencedColumns.Count > 0 + && fk.Pair.ReferencingDbTable.Equals(targetDbo)); + + ForeignKeyDefinition? sourceReferencingFKInfo = sourceReferencingForeignKeysInfo.FirstOrDefault(); + if (sourceReferencingFKInfo is not null) + { + // When source entity is the referencing entity, referencing field directive is to be added to relationship fields + // in the source entity. + AddReferencingFieldDirectiveToReferencingFields(sourceFieldDefinitions, sourceReferencingFKInfo.ReferencingColumns, sqlMetadataProvider, sourceEntityName); + } + + ForeignKeyDefinition? targetReferencingFKInfo = targetReferencingForeignKeysInfo.FirstOrDefault(); + if (targetReferencingFKInfo is not null && + objectTypes.TryGetValue(targetEntityName, out ObjectTypeDefinitionNode? targetObjectTypeDefinitionNode)) + { + Dictionary targetFieldDefinitions = targetObjectTypeDefinitionNode.Fields.ToDictionary(field => field.Name.Value, field => field); + // When target entity is the referencing entity, referencing field directive is to be added to relationship fields + // in the target entity. + AddReferencingFieldDirectiveToReferencingFields(targetFieldDefinitions, targetReferencingFKInfo.ReferencingColumns, sqlMetadataProvider, targetEntityName); + + // Update the target object definition with the new set of fields having referencing field directive. + objectTypes[targetEntityName] = targetObjectTypeDefinitionNode.WithFields(new List(targetFieldDefinitions.Values)); + } + } + } + + // Update the source object definition with the new set of fields having referencing field directive. + objectTypes[sourceEntityName] = sourceObjectTypeDefinitionNode.WithFields(new List(sourceFieldDefinitions.Values)); + } + } + + /// + /// Helper method to add referencing field directive type to all the fields in the entity which + /// hold a foreign key reference to another entity exposed in the config, related via a relationship. + /// + /// Field definitions of the referencing entity. + /// Referencing columns in the relationship. + private static void AddReferencingFieldDirectiveToReferencingFields( + Dictionary referencingEntityFieldDefinitions, + List referencingColumns, + ISqlMetadataProvider metadataProvider, + string entityName) + { + foreach (string referencingColumn in referencingColumns) + { + if (metadataProvider.TryGetExposedColumnName(entityName, referencingColumn, out string? exposedReferencingColumnName) && + referencingEntityFieldDefinitions.TryGetValue(exposedReferencingColumnName, out FieldDefinitionNode? referencingFieldDefinition)) + { + if (!referencingFieldDefinition.Directives.Any(directive => directive.Name.Value == ReferencingFieldDirectiveType.DirectiveName)) + { + List directiveNodes = referencingFieldDefinition.Directives.ToList(); + directiveNodes.Add(new DirectiveNode(ReferencingFieldDirectiveType.DirectiveName)); + referencingEntityFieldDefinitions[exposedReferencingColumnName] = referencingFieldDefinition.WithDirectives(directiveNodes); + } + } + } + } + + /// + /// Helper method to generate object definitions for linking entities. These object definitions are used later + /// to generate the object definitions for directional linking entities for (source, target) and (target, source). + /// + /// Object definitions for linking entities. + private Dictionary GenerateObjectDefinitionsForLinkingEntities() + { + IEnumerable sqlMetadataProviders = _metadataProviderFactory.ListMetadataProviders(); + Dictionary linkingObjectTypes = new(); + foreach (ISqlMetadataProvider sqlMetadataProvider in sqlMetadataProviders) + { + foreach ((string linkingEntityName, Entity linkingEntity) in sqlMetadataProvider.GetLinkingEntities()) + { + if (sqlMetadataProvider.GetEntityNamesAndDbObjects().TryGetValue(linkingEntityName, out DatabaseObject? linkingDbObject)) + { + ObjectTypeDefinitionNode node = SchemaConverter.GenerateObjectTypeDefinitionForDatabaseObject( + entityName: linkingEntityName, + databaseObject: linkingDbObject, + configEntity: linkingEntity, + entities: new(new Dictionary()), + rolesAllowedForEntity: new List(), + rolesAllowedForFields: new Dictionary>() + ); + + linkingObjectTypes.Add(linkingEntityName, node); + } + } + } + + return linkingObjectTypes; + } + + /// + /// Helper method to generate object types for linking nodes from (source, target) using + /// simple linking nodes which represent a linking table linking the source and target tables which have an M:N relationship between them. + /// A 'sourceTargetLinkingNode' will contain: + /// 1. All the fields (column/relationship) from the target node, + /// 2. Column fields from the linking node which are not part of the Foreign key constraint (or relationship fields when the relationship + /// is defined in the config). + /// + /// + /// Target node definition contains fields: TField1, TField2, TField3 + /// Linking node definition contains fields: LField1, LField2, LField3 + /// Relationship : linkingTable(Lfield3) -> targetTable(TField3) + /// + /// Result: + /// SourceTargetLinkingNodeDefinition contains fields: + /// 1. TField1, TField2, TField3 (All the fields from the target node.) + /// 2. LField1, LField2 (Non-relationship fields from linking table.) + /// + /// Collection of object types. + /// Collection of object types for linking entities. + private void GenerateSourceTargetLinkingObjectDefinitions( + Dictionary objectTypes, + Dictionary linkingObjectTypes) + { + foreach ((string linkingEntityName, ObjectTypeDefinitionNode linkingObjectDefinition) in linkingObjectTypes) + { + (string sourceEntityName, string targetEntityName) = GraphQLUtils.GetSourceAndTargetEntityNameFromLinkingEntityName(linkingEntityName); + string dataSourceName = _runtimeConfigProvider.GetConfig().GetDataSourceNameFromEntityName(targetEntityName); + ISqlMetadataProvider sqlMetadataProvider = _metadataProviderFactory.GetMetadataProvider(dataSourceName); + if (sqlMetadataProvider.GetEntityNamesAndDbObjects().TryGetValue(sourceEntityName, out DatabaseObject? sourceDbo)) + { + IEnumerable foreignKeyDefinitionsFromSourceToTarget = sourceDbo.SourceDefinition.SourceEntityRelationshipMap[sourceEntityName].TargetEntityToFkDefinitionMap[targetEntityName]; + + // Get list of all referencing columns from the foreign key definition. For an M:N relationship, + // all the referencing columns belong to the linking entity. + HashSet referencingColumnNamesInLinkingEntity = new(foreignKeyDefinitionsFromSourceToTarget.SelectMany(foreignKeyDefinition => foreignKeyDefinition.ReferencingColumns).ToList()); + + // Store the names of relationship/column fields in the target entity to prevent conflicting names + // with the linking table's column fields. + ObjectTypeDefinitionNode targetNode = objectTypes[targetEntityName]; + HashSet fieldNamesInTarget = targetNode.Fields.Select(field => field.Name.Value).ToHashSet(); + + // Initialize list of fields in the sourceTargetLinkingNode with the set of fields present in the target node. + List fieldsInSourceTargetLinkingNode = targetNode.Fields.ToList(); + + // Get list of fields in the linking node (which represents columns present in the linking table). + List fieldsInLinkingNode = linkingObjectDefinition.Fields.ToList(); + + // The sourceTargetLinkingNode will contain: + // 1. All the fields from the target node to perform insertion on the target entity, + // 2. Fields from the linking node which are not a foreign key reference to source or target node. This is needed to perform + // an insertion in the linking table. For the foreign key columns in linking table, the values are derived from the insertions in the + // source and the target table. For the rest of the columns, the value will be provided via a field exposed in the sourceTargetLinkingNode. + foreach (FieldDefinitionNode fieldInLinkingNode in fieldsInLinkingNode) + { + string fieldName = fieldInLinkingNode.Name.Value; + if (!referencingColumnNamesInLinkingEntity.Contains(fieldName)) + { + if (fieldNamesInTarget.Contains(fieldName)) + { + // The fieldName can represent a column in the targetEntity or a relationship. + // The fieldName in the linking node cannot conflict with any of the + // existing field names (either column name or relationship name) in the target node. + bool doesFieldRepresentAColumn = sqlMetadataProvider.TryGetBackingColumn(targetEntityName, fieldName, out string? _); + string infoMsg = $"Cannot use field name '{fieldName}' as it conflicts with another field's name in the entity: {targetEntityName}. "; + string actionableMsg = doesFieldRepresentAColumn ? + $"Consider using the 'mappings' section of the {targetEntityName} entity configuration to provide some other name for the field: '{fieldName}'." : + $"Consider using the 'relationships' section of the {targetEntityName} entity configuration to provide some other name for the relationship: '{fieldName}'."; + throw new DataApiBuilderException( + message: infoMsg + actionableMsg, + statusCode: HttpStatusCode.ServiceUnavailable, + subStatusCode: DataApiBuilderException.SubStatusCodes.ErrorInInitialization); + } + else + { + fieldsInSourceTargetLinkingNode.Add(fieldInLinkingNode); + } + } + } + + // Store object type of the linking node for (sourceEntityName, targetEntityName). + NameNode sourceTargetLinkingNodeName = new(GenerateLinkingNodeName( + objectTypes[sourceEntityName].Name.Value, + targetNode.Name.Value)); + objectTypes.TryAdd(sourceTargetLinkingNodeName.Value, + new( + location: null, + name: sourceTargetLinkingNodeName, + description: null, + new List() { }, + new List(), + fieldsInSourceTargetLinkingNode)); + } + } + } + /// /// Generates the ObjectTypeDefinitionNodes and InputObjectTypeDefinitionNodes as part of GraphQL Schema generation for cosmos db. /// Each datasource in cosmos has a root file provided which is used to generate the schema. diff --git a/src/Core/Services/MetadataProviders/ISqlMetadataProvider.cs b/src/Core/Services/MetadataProviders/ISqlMetadataProvider.cs index c79958e1d9..8b92e5a3a6 100644 --- a/src/Core/Services/MetadataProviders/ISqlMetadataProvider.cs +++ b/src/Core/Services/MetadataProviders/ISqlMetadataProvider.cs @@ -53,6 +53,11 @@ bool VerifyForeignKeyExistsInDB( /// FieldDefinitionNode? GetSchemaGraphQLFieldFromFieldName(string entityName, string fieldName); + /// + /// Gets a collection of linking entities generated by DAB (required to support multiple mutations). + /// + IReadOnlyDictionary GetLinkingEntities() => new Dictionary(); + /// /// Obtains the underlying SourceDefinition for the given entity name. /// diff --git a/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs b/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs index 3bc4e876e1..60e8543512 100644 --- a/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs +++ b/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs @@ -13,6 +13,7 @@ using Azure.DataApiBuilder.Core.Resolvers; using Azure.DataApiBuilder.Core.Resolvers.Factories; using Azure.DataApiBuilder.Service.Exceptions; +using Azure.DataApiBuilder.Service.GraphQLBuilder; using Microsoft.Data.SqlClient; using Microsoft.Extensions.Logging; using static Azure.DataApiBuilder.Service.GraphQLBuilder.GraphQLNaming; @@ -211,6 +212,39 @@ protected override async Task FillSchemaForStoredProcedureAsync( GraphQLStoredProcedureExposedNameToEntityNameMap.TryAdd(GenerateStoredProcedureGraphQLFieldName(entityName, procedureEntity), entityName); } + /// + protected override void PopulateMetadataForLinkingObject( + string entityName, + string targetEntityName, + string linkingObject, + Dictionary sourceObjects) + { + if (!GraphQLUtils.DoesRelationalDBSupportMultipleCreate(GetDatabaseType())) + { + // Currently we have this same class instantiated for both MsSql and DwSql. + // This is a refactor we need to take care of in future. + return; + } + + string linkingEntityName = GraphQLUtils.GenerateLinkingEntityName(entityName, targetEntityName); + + // Create linking entity with disabled REST/GraphQL endpoints. + // Even though GraphQL endpoint is disabled, we will be able to later create an object type definition + // for this linking entity (which is later used to generate source->target linking object definition) + // because the logic for creation of object definition for linking entity does not depend on whether + // GraphQL is enabled/disabled. The linking object definitions are not exposed in the schema to the user. + Entity linkingEntity = new( + Source: new EntitySource(Type: EntitySourceType.Table, Object: linkingObject, Parameters: null, KeyFields: null), + Rest: new(Array.Empty(), Enabled: false), + GraphQL: new(Singular: linkingEntityName, Plural: linkingEntityName, Enabled: false), + Permissions: Array.Empty(), + Relationships: null, + Mappings: new(), + IsLinkingEntity: true); + _linkingEntities.TryAdd(linkingEntityName, linkingEntity); + PopulateDatabaseObjectForEntity(linkingEntity, linkingEntityName, sourceObjects); + } + /// /// Takes a string version of a sql date/time type and returns its corresponding DbType. /// diff --git a/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs b/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs index 65027306f2..144f7c0df9 100644 --- a/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs +++ b/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs @@ -37,7 +37,11 @@ public abstract class SqlMetadataProvider : private readonly DatabaseType _databaseType; - private readonly IReadOnlyDictionary _entities; + // Represents the entities exposed in the runtime config. + private IReadOnlyDictionary _entities; + + // Represents the linking entities created by DAB to support multiple mutations for entities having an M:N relationship between them. + protected Dictionary _linkingEntities = new(); protected readonly string _dataSourceName; @@ -599,70 +603,76 @@ protected virtual Dictionary /// private void GenerateDatabaseObjectForEntities() { - string schemaName, dbObjectName; Dictionary sourceObjects = new(); foreach ((string entityName, Entity entity) in _entities) { - try - { - EntitySourceType sourceType = GetEntitySourceType(entityName, entity); + PopulateDatabaseObjectForEntity(entity, entityName, sourceObjects); + } + } - if (!EntityToDatabaseObject.ContainsKey(entityName)) + protected void PopulateDatabaseObjectForEntity( + Entity entity, + string entityName, + Dictionary sourceObjects) + { + try + { + EntitySourceType sourceType = GetEntitySourceType(entityName, entity); + if (!EntityToDatabaseObject.ContainsKey(entityName)) + { + // Reuse the same Database object for multiple entities if they share the same source. + if (!sourceObjects.TryGetValue(entity.Source.Object, out DatabaseObject? sourceObject)) { - // Reuse the same Database object for multiple entities if they share the same source. - if (!sourceObjects.TryGetValue(entity.Source.Object, out DatabaseObject? sourceObject)) - { - // parse source name into a tuple of (schemaName, databaseObjectName) - (schemaName, dbObjectName) = ParseSchemaAndDbTableName(entity.Source.Object)!; + // parse source name into a tuple of (schemaName, databaseObjectName) + (string schemaName, string dbObjectName) = ParseSchemaAndDbTableName(entity.Source.Object)!; - // if specified as stored procedure in config, - // initialize DatabaseObject as DatabaseStoredProcedure, - // else with DatabaseTable (for tables) / DatabaseView (for views). + // if specified as stored procedure in config, + // initialize DatabaseObject as DatabaseStoredProcedure, + // else with DatabaseTable (for tables) / DatabaseView (for views). - if (sourceType is EntitySourceType.StoredProcedure) + if (sourceType is EntitySourceType.StoredProcedure) + { + sourceObject = new DatabaseStoredProcedure(schemaName, dbObjectName) { - sourceObject = new DatabaseStoredProcedure(schemaName, dbObjectName) - { - SourceType = sourceType, - StoredProcedureDefinition = new() - }; - } - else if (sourceType is EntitySourceType.Table) + SourceType = sourceType, + StoredProcedureDefinition = new() + }; + } + else if (sourceType is EntitySourceType.Table) + { + sourceObject = new DatabaseTable() { - sourceObject = new DatabaseTable() - { - SchemaName = schemaName, - Name = dbObjectName, - SourceType = sourceType, - TableDefinition = new() - }; - } - else + SchemaName = schemaName, + Name = dbObjectName, + SourceType = sourceType, + TableDefinition = new() + }; + } + else + { + sourceObject = new DatabaseView(schemaName, dbObjectName) { - sourceObject = new DatabaseView(schemaName, dbObjectName) - { - SchemaName = schemaName, - Name = dbObjectName, - SourceType = sourceType, - ViewDefinition = new() - }; - } - - sourceObjects.Add(entity.Source.Object, sourceObject); + SchemaName = schemaName, + Name = dbObjectName, + SourceType = sourceType, + ViewDefinition = new() + }; } - EntityToDatabaseObject.Add(entityName, sourceObject); + sourceObjects.Add(entity.Source.Object, sourceObject); + } - if (entity.Relationships is not null && entity.Source.Type is EntitySourceType.Table) - { - AddForeignKeysForRelationships(entityName, entity, (DatabaseTable)sourceObject); - } + EntityToDatabaseObject.Add(entityName, sourceObject); + + if (entity.Relationships is not null && entity.Source.Type is EntitySourceType.Table) + { + ProcessRelationships(entityName, entity, (DatabaseTable)sourceObject, sourceObjects); } } - catch (Exception e) - { - HandleOrRecordException(e); - } + } + catch (Exception e) + { + HandleOrRecordException(e); } } @@ -698,10 +708,11 @@ private static EntitySourceType GetEntitySourceType(string entityName, Entity en /// /// /// - private void AddForeignKeysForRelationships( + private void ProcessRelationships( string entityName, Entity entity, - DatabaseTable databaseTable) + DatabaseTable databaseTable, + Dictionary sourceObjects) { SourceDefinition sourceDefinition = GetSourceDefinition(entityName); if (!sourceDefinition.SourceEntityRelationshipMap @@ -743,6 +754,18 @@ private void AddForeignKeysForRelationships( referencingColumns: relationship.LinkingTargetFields, referencedColumns: relationship.TargetFields, relationshipData); + + // When a linking object is encountered for a database table, we will create a linking entity for the object. + // Subsequently, we will also populate the Database object for the linking entity. This is used to infer + // metadata about linking object needed to create GQL schema for multiple insertions. + if (entity.Source.Type is EntitySourceType.Table) + { + PopulateMetadataForLinkingObject( + entityName: entityName, + targetEntityName: targetEntityName, + linkingObject: relationship.LinkingObject, + sourceObjects: sourceObjects); + } } else if (relationship.Cardinality == Cardinality.One) { @@ -800,6 +823,24 @@ private void AddForeignKeysForRelationships( } } + /// + /// Helper method to create a linking entity and a database object for the given linking object (which relates the source and target with an M:N relationship). + /// The created linking entity and its corresponding database object definition is later used during GraphQL schema generation + /// to enable multiple mutations. + /// + /// Source entity name. + /// Target entity name. + /// Linking object + /// Dictionary storing a collection of database objects which have been created. + protected virtual void PopulateMetadataForLinkingObject( + string entityName, + string targetEntityName, + string linkingObject, + Dictionary sourceObjects) + { + return; + } + /// /// Adds a new foreign key definition for the target entity /// in the relationship metadata. @@ -897,6 +938,11 @@ public List GetSchemaGraphQLFieldNamesForEntityName(string entityName) public FieldDefinitionNode? GetSchemaGraphQLFieldFromFieldName(string graphQLType, string fieldName) => throw new NotImplementedException(); + public IReadOnlyDictionary GetLinkingEntities() + { + return _linkingEntities; + } + /// /// Enrich the entities in the runtime config with the /// object definition information needed by the runtime to serve requests. @@ -907,53 +953,63 @@ private async Task PopulateObjectDefinitionForEntities() { foreach ((string entityName, Entity entity) in _entities) { - try + await PopulateObjectDefinitionForEntity(entityName, entity); + } + + foreach ((string entityName, Entity entity) in _linkingEntities) + { + await PopulateObjectDefinitionForEntity(entityName, entity); + } + + await PopulateForeignKeyDefinitionAsync(); + } + + private async Task PopulateObjectDefinitionForEntity(string entityName, Entity entity) + { + try + { + EntitySourceType entitySourceType = GetEntitySourceType(entityName, entity); + if (entitySourceType is EntitySourceType.StoredProcedure) { - EntitySourceType entitySourceType = GetEntitySourceType(entityName, entity); - if (entitySourceType is EntitySourceType.StoredProcedure) + await FillSchemaForStoredProcedureAsync( + entity, + entityName, + GetSchemaName(entityName), + GetDatabaseObjectName(entityName), + GetStoredProcedureDefinition(entityName)); + + if (GetDatabaseType() == DatabaseType.MSSQL || GetDatabaseType() == DatabaseType.DWSQL) { - await FillSchemaForStoredProcedureAsync( - entity, - entityName, + await PopulateResultSetDefinitionsForStoredProcedureAsync( GetSchemaName(entityName), GetDatabaseObjectName(entityName), GetStoredProcedureDefinition(entityName)); - - if (GetDatabaseType() == DatabaseType.MSSQL || GetDatabaseType() == DatabaseType.DWSQL) - { - await PopulateResultSetDefinitionsForStoredProcedureAsync( - GetSchemaName(entityName), - GetDatabaseObjectName(entityName), - GetStoredProcedureDefinition(entityName)); - } - } - else if (entitySourceType is EntitySourceType.Table) - { - await PopulateSourceDefinitionAsync( - entityName, - GetSchemaName(entityName), - GetDatabaseObjectName(entityName), - GetSourceDefinition(entityName), - entity.Source.KeyFields); - } - else - { - ViewDefinition viewDefinition = (ViewDefinition)GetSourceDefinition(entityName); - await PopulateSourceDefinitionAsync( - entityName, - GetSchemaName(entityName), - GetDatabaseObjectName(entityName), - viewDefinition, - entity.Source.KeyFields); } } - catch (Exception e) + else if (entitySourceType is EntitySourceType.Table) { - HandleOrRecordException(e); + await PopulateSourceDefinitionAsync( + entityName, + GetSchemaName(entityName), + GetDatabaseObjectName(entityName), + GetSourceDefinition(entityName), + entity.Source.KeyFields); + } + else + { + ViewDefinition viewDefinition = (ViewDefinition)GetSourceDefinition(entityName); + await PopulateSourceDefinitionAsync( + entityName, + GetSchemaName(entityName), + GetDatabaseObjectName(entityName), + viewDefinition, + entity.Source.KeyFields); } } - - await PopulateForeignKeyDefinitionAsync(); + catch (Exception e) + { + HandleOrRecordException(e); + } } /// diff --git a/src/Core/Services/OpenAPI/OpenApiDocumentor.cs b/src/Core/Services/OpenAPI/OpenApiDocumentor.cs index f482076f88..ebe09223e4 100644 --- a/src/Core/Services/OpenAPI/OpenApiDocumentor.cs +++ b/src/Core/Services/OpenAPI/OpenApiDocumentor.cs @@ -178,6 +178,12 @@ private OpenApiPaths BuildPaths() foreach (KeyValuePair entityDbMetadataMap in metadataProvider.EntityToDatabaseObject) { string entityName = entityDbMetadataMap.Key; + if (!_runtimeConfig.Entities.ContainsKey(entityName)) + { + // This can happen for linking entities which are not present in runtime config. + continue; + } + string entityRestPath = GetEntityRestPath(entityName); string entityBasePathComponent = $"/{entityRestPath}"; @@ -962,12 +968,12 @@ private Dictionary CreateComponentSchemas() string entityName = entityDbMetadataMap.Key; DatabaseObject dbObject = entityDbMetadataMap.Value; - if (_runtimeConfig.Entities.TryGetValue(entityName, out Entity? entity) && entity is not null) + if (!_runtimeConfig.Entities.TryGetValue(entityName, out Entity? entity) || !entity.Rest.Enabled) { - if (!entity.Rest.Enabled) - { - continue; - } + // Don't create component schemas for: + // 1. Linking entity: The entity will be null when we are dealing with a linking entity, which is not exposed in the config. + // 2. Entity for which REST endpoint is disabled. + continue; } SourceDefinition sourceDefinition = metadataProvider.GetSourceDefinition(entityName); diff --git a/src/Core/Services/RequestValidator.cs b/src/Core/Services/RequestValidator.cs index 7519394f64..fff83c922f 100644 --- a/src/Core/Services/RequestValidator.cs +++ b/src/Core/Services/RequestValidator.cs @@ -485,8 +485,9 @@ public void ValidateEntity(string entityName) { ISqlMetadataProvider sqlMetadataProvider = GetSqlMetadataProvider(entityName); IEnumerable entities = sqlMetadataProvider.EntityToDatabaseObject.Keys; - if (!entities.Contains(entityName)) + if (!entities.Contains(entityName) || sqlMetadataProvider.GetLinkingEntities().ContainsKey(entityName)) { + // Do not validate the entity if the entity definition does not exist or if the entity is a linking entity. throw new DataApiBuilderException( message: $"{entityName} is not a valid entity.", statusCode: HttpStatusCode.NotFound, diff --git a/src/Service.GraphQLBuilder/Directives/ReferencingFieldDirectiveType.cs b/src/Service.GraphQLBuilder/Directives/ReferencingFieldDirectiveType.cs new file mode 100644 index 0000000000..460e7b14e2 --- /dev/null +++ b/src/Service.GraphQLBuilder/Directives/ReferencingFieldDirectiveType.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using HotChocolate.Types; + +namespace Azure.DataApiBuilder.Service.GraphQLBuilder.Directives +{ + public class ReferencingFieldDirectiveType : DirectiveType + { + public static string DirectiveName { get; } = "dab_referencingField"; + + protected override void Configure(IDirectiveTypeDescriptor descriptor) + { + descriptor + .Name(DirectiveName) + .Description("When present on a field in a database table, indicates that the field is a referencing field " + + "to some field in the same/another database table.") + .Location(DirectiveLocation.FieldDefinition); + } + } +} diff --git a/src/Service.GraphQLBuilder/GraphQLNaming.cs b/src/Service.GraphQLBuilder/GraphQLNaming.cs index 101b537d09..3e31ee08bb 100644 --- a/src/Service.GraphQLBuilder/GraphQLNaming.cs +++ b/src/Service.GraphQLBuilder/GraphQLNaming.cs @@ -28,6 +28,8 @@ public static class GraphQLNaming /// public const string INTROSPECTION_FIELD_PREFIX = "__"; + public const string LINKING_OBJECT_PREFIX = "linkingObject"; + /// /// Enforces the GraphQL naming restrictions on . /// Completely removes invalid characters from the input parameter: name. @@ -92,7 +94,7 @@ public static bool IsIntrospectionField(string fieldName) /// /// Attempts to deserialize and get the SingularPlural GraphQL naming config - /// of an Entity from the Runtime Configuration. + /// of an Entity from the Runtime Configuration and return the singular name of the entity. /// public static string GetDefinedSingularName(string entityName, Entity configEntity) { @@ -104,6 +106,20 @@ public static string GetDefinedSingularName(string entityName, Entity configEnti return configEntity.GraphQL.Singular; } + /// + /// Attempts to deserialize and get the SingularPlural GraphQL naming config + /// of an Entity from the Runtime Configuration and return the plural name of the entity. + /// + public static string GetDefinedPluralName(string entityName, Entity configEntity) + { + if (string.IsNullOrEmpty(configEntity.GraphQL.Plural)) + { + throw new ArgumentException($"The entity '{entityName}' does not have a plural name defined in config, nor has one been extrapolated from the entity name."); + } + + return configEntity.GraphQL.Plural; + } + /// /// Format fields generated by the runtime aligning with /// GraphQL best practices. @@ -185,5 +201,14 @@ public static string GenerateStoredProcedureGraphQLFieldName(string entityName, string preformattedField = $"execute{GetDefinedSingularName(entityName, entity)}"; return FormatNameForField(preformattedField); } + + /// + /// Helper method to generate the linking node name from source to target entities having a relationship + /// with cardinality M:N between them. + /// + public static string GenerateLinkingNodeName(string sourceNodeName, string targetNodeName) + { + return LINKING_OBJECT_PREFIX + sourceNodeName + targetNodeName; + } } } diff --git a/src/Service.GraphQLBuilder/GraphQLUtils.cs b/src/Service.GraphQLBuilder/GraphQLUtils.cs index 7128b2049b..78ad23d925 100644 --- a/src/Service.GraphQLBuilder/GraphQLUtils.cs +++ b/src/Service.GraphQLBuilder/GraphQLUtils.cs @@ -26,6 +26,16 @@ public static class GraphQLUtils public const string DB_OPERATION_RESULT_TYPE = "DbOperationResult"; public const string DB_OPERATION_RESULT_FIELD_NAME = "result"; + // String used as a prefix for the name of a linking entity. + private const string LINKING_ENTITY_PREFIX = "LinkingEntity"; + // Delimiter used to separate linking entity prefix/source entity name/target entity name, in the name of a linking entity. + private const string ENTITY_NAME_DELIMITER = "$"; + + public static HashSet RELATIONAL_DBS_SUPPORTING_MULTIPLE_CREATE = new() { DatabaseType.MSSQL }; + + public static HashSet RELATIONAL_DBS = new() { DatabaseType.MSSQL, DatabaseType.MySQL, + DatabaseType.DWSQL, DatabaseType.PostgreSQL, DatabaseType.CosmosDB_PostgreSQL }; + public static bool IsModelType(ObjectTypeDefinitionNode objectTypeDefinitionNode) { string modelDirectiveName = ModelDirectiveType.DirectiveName; @@ -61,6 +71,22 @@ public static bool IsBuiltInType(ITypeNode typeNode) return builtInTypes.Contains(name); } + /// + /// Helper method to evaluate whether DAB supports multiple create for a particular database type. + /// + public static bool DoesRelationalDBSupportMultipleCreate(DatabaseType databaseType) + { + return RELATIONAL_DBS_SUPPORTING_MULTIPLE_CREATE.Contains(databaseType); + } + + /// + /// Helper method to evaluate whether database type represents a NoSQL database. + /// + public static bool IsRelationalDb(DatabaseType databaseType) + { + return RELATIONAL_DBS.Contains(databaseType); + } + /// /// Find all the primary keys for a given object node /// using the information available in the directives. @@ -306,5 +332,39 @@ private static string GenerateDataSourceNameKeyFromPath(IPureResolverContext con { return $"{context.Path.ToList()[0]}"; } + + /// + /// Helper method to generate the linking entity name using the source and target entity names. + /// + /// Source entity name. + /// Target entity name. + /// Name of the linking entity 'LinkingEntity$SourceEntityName$TargetEntityName'. + public static string GenerateLinkingEntityName(string source, string target) + { + return LINKING_ENTITY_PREFIX + ENTITY_NAME_DELIMITER + source + ENTITY_NAME_DELIMITER + target; + } + + /// + /// Helper method to decode the names of source and target entities from the name of a linking entity. + /// + /// linking entity name of the format 'LinkingEntity$SourceEntityName$TargetEntityName'. + /// tuple of source, target entities name of the format (SourceEntityName, TargetEntityName). + /// Thrown when the linking entity name is not of the expected format. + public static Tuple GetSourceAndTargetEntityNameFromLinkingEntityName(string linkingEntityName) + { + if (!linkingEntityName.StartsWith(LINKING_ENTITY_PREFIX + ENTITY_NAME_DELIMITER)) + { + throw new ArgumentException("The provided entity name is an invalid linking entity name."); + } + + string[] sourceTargetEntityNames = linkingEntityName.Split(ENTITY_NAME_DELIMITER, StringSplitOptions.RemoveEmptyEntries); + + if (sourceTargetEntityNames.Length != 3) + { + throw new ArgumentException("The provided entity name is an invalid linking entity name."); + } + + return new(sourceTargetEntityNames[1], sourceTargetEntityNames[2]); + } } } diff --git a/src/Service.GraphQLBuilder/Mutations/CreateMutationBuilder.cs b/src/Service.GraphQLBuilder/Mutations/CreateMutationBuilder.cs index ac93a7ffa3..62b35c7e2e 100644 --- a/src/Service.GraphQLBuilder/Mutations/CreateMutationBuilder.cs +++ b/src/Service.GraphQLBuilder/Mutations/CreateMutationBuilder.cs @@ -15,22 +15,28 @@ namespace Azure.DataApiBuilder.Service.GraphQLBuilder.Mutations { public static class CreateMutationBuilder { + private const string CREATE_MULTIPLE_MUTATION_SUFFIX = "Multiple"; public const string INPUT_ARGUMENT_NAME = "item"; + public const string CREATE_MUTATION_PREFIX = "create"; /// - /// Generate the GraphQL input type from an object type + /// Generate the GraphQL input type from an object type for relational database. /// /// Reference table of all known input types. /// GraphQL object to generate the input type for. /// Name of the GraphQL object type. + /// In case when we are creating input type for linking object, baseEntityName is equal to the targetEntityName, + /// else baseEntityName is equal to the name parameter. /// All named GraphQL items in the schema (objects, enums, scalars, etc.) - /// Database type to generate input type for. + /// Database type of the relational database to generate input type for. /// Runtime config information. /// A GraphQL input type with all expected fields mapped as GraphQL inputs. - private static InputObjectTypeDefinitionNode GenerateCreateInputType( + private static InputObjectTypeDefinitionNode GenerateCreateInputTypeForRelationalDb( Dictionary inputs, ObjectTypeDefinitionNode objectTypeDefinitionNode, + string entityName, NameNode name, + NameNode baseEntityName, IEnumerable definitions, DatabaseType databaseType, RuntimeEntities entities) @@ -42,31 +48,166 @@ private static InputObjectTypeDefinitionNode GenerateCreateInputType( return inputs[inputName]; } - IEnumerable inputFields = - objectTypeDefinitionNode.Fields - .Where(f => FieldAllowedOnCreateInput(f, databaseType, definitions)) - .Select(f => + // The input fields for a create object will be a combination of: + // 1. Scalar input fields corresponding to columns which belong to the table. + // 2. Complex input fields corresponding to related (target) entities (table backed entities, for now) + // which are defined in the runtime config. + List inputFields = new(); + + // 1. Scalar input fields. + IEnumerable scalarInputFields = objectTypeDefinitionNode.Fields + .Where(field => IsBuiltInType(field.Type) && !IsAutoGeneratedField(field)) + .Select(field => { - if (!IsBuiltInType(f.Type)) + return GenerateScalarInputType(name, field, databaseType); + }); + + // Add scalar input fields to list of input fields for current input type. + inputFields.AddRange(scalarInputFields); + + // Create input object for this entity. + InputObjectTypeDefinitionNode input = + new( + location: null, + inputName, + new StringValueNode($"Input type for creating {name}"), + new List(), + inputFields + ); + + // Add input object to the dictionary of entities for which input object has already been created. + // This input object currently holds only scalar fields. + // The complex fields (for related entities) would be added later when we return from recursion. + // Adding the input object to the dictionary ensures that we don't go into infinite recursion and return whenever + // we find that the input object has already been created for the entity. + inputs.Add(input.Name, input); + + // Generate fields for related entities only if multiple mutations are supported for the database flavor. + if (DoesRelationalDBSupportMultipleCreate(databaseType)) + { + // 2. Complex input fields. + // Evaluate input objects for related entities. + IEnumerable complexInputFields = + objectTypeDefinitionNode.Fields + .Where(field => !IsBuiltInType(field.Type) && IsComplexFieldAllowedForCreateInputInRelationalDb(field, databaseType, definitions)) + .Select(field => { - string typeName = RelationshipDirectiveType.Target(f); - HotChocolate.Language.IHasName? def = definitions.FirstOrDefault(d => d.Name.Value == typeName); + string typeName = RelationshipDirectiveType.Target(field); + HotChocolate.Language.IHasName? def = definitions.FirstOrDefault(d => d.Name.Value.Equals(typeName)); if (def is null) { - throw new DataApiBuilderException($"The type {typeName} is not a known GraphQL type, and cannot be used in this schema.", HttpStatusCode.InternalServerError, DataApiBuilderException.SubStatusCodes.GraphQLMapping); + throw new DataApiBuilderException( + message: $"The type {typeName} is not a known GraphQL type, and cannot be used in this schema.", + statusCode: HttpStatusCode.ServiceUnavailable, + subStatusCode: DataApiBuilderException.SubStatusCodes.ErrorInInitialization); } - if (def is ObjectTypeDefinitionNode otdn) + if (!entities.TryGetValue(entityName, out Entity? entity) || entity.Relationships is null) { - //Get entity definition for this ObjectTypeDefinitionNode - return GetComplexInputType(inputs, definitions, f, typeName, otdn, databaseType, entities); + throw new DataApiBuilderException( + message: $"Could not find entity metadata for entity: {entityName}.", + statusCode: HttpStatusCode.ServiceUnavailable, + subStatusCode: DataApiBuilderException.SubStatusCodes.ErrorInInitialization); } + + string targetEntityName = entity.Relationships[field.Name.Value].TargetEntity; + if (IsMToNRelationship(entity, field.Name.Value)) + { + // The field can represent a related entity with M:N relationship with the parent. + NameNode baseObjectTypeNameForField = new(typeName); + typeName = GenerateLinkingNodeName(baseEntityName.Value, typeName); + def = (ObjectTypeDefinitionNode)definitions.FirstOrDefault(d => d.Name.Value.Equals(typeName))!; + + // Get entity definition for this ObjectTypeDefinitionNode. + // Recurse for evaluating input objects for related entities. + return GenerateComplexInputTypeForRelationalDb( + entityName: targetEntityName, + inputs: inputs, + definitions: definitions, + field: field, + typeName: typeName, + targetObjectTypeName: baseObjectTypeNameForField, + objectTypeDefinitionNode: (ObjectTypeDefinitionNode)def, + databaseType: databaseType, + entities: entities); + } + + // Get entity definition for this ObjectTypeDefinitionNode. + // Recurse for evaluating input objects for related entities. + return GenerateComplexInputTypeForRelationalDb( + entityName: targetEntityName, + inputs: inputs, + definitions: definitions, + field: field, + typeName: typeName, + targetObjectTypeName: new(typeName), + objectTypeDefinitionNode: (ObjectTypeDefinitionNode)def, + databaseType: databaseType, + entities: entities); + }); + // Append relationship fields to the input fields. + inputFields.AddRange(complexInputFields); + } + + return input; + } + + /// + /// Generate the GraphQL input type from an object type for non-relational database. + /// + /// Reference table of all known input types. + /// GraphQL object to generate the input type for. + /// Name of the GraphQL object type. + /// All named GraphQL items in the schema (objects, enums, scalars, etc.) + /// Database type of the non-relational database to generate input type for. + /// Runtime config information. + /// A GraphQL input type with all expected fields mapped as GraphQL inputs. + private static InputObjectTypeDefinitionNode GenerateCreateInputTypeForNonRelationalDb( + Dictionary inputs, + ObjectTypeDefinitionNode objectTypeDefinitionNode, + NameNode name, + IEnumerable definitions, + DatabaseType databaseType) + { + NameNode inputName = GenerateInputTypeName(name.Value); + + if (inputs.ContainsKey(inputName)) + { + return inputs[inputName]; + } + + IEnumerable inputFields = + objectTypeDefinitionNode.Fields + .Select(field => + { + if (IsBuiltInType(field.Type)) + { + return GenerateScalarInputType(name, field, databaseType); } - return GenerateSimpleInputType(name, f); + string typeName = RelationshipDirectiveType.Target(field); + HotChocolate.Language.IHasName? def = definitions.FirstOrDefault(d => d.Name.Value.Equals(typeName)); + + if (def is null) + { + throw new DataApiBuilderException( + message: $"The type {typeName} is not a known GraphQL type, and cannot be used in this schema.", + statusCode: HttpStatusCode.ServiceUnavailable, + subStatusCode: DataApiBuilderException.SubStatusCodes.ErrorInInitialization); + } + + //Get entity definition for this ObjectTypeDefinitionNode + return GenerateComplexInputTypeForNonRelationalDb( + inputs: inputs, + definitions: definitions, + field: field, + typeName: typeName, + objectTypeDefinitionNode: (ObjectTypeDefinitionNode)def, + databaseType: databaseType); }); + // Create input object for this entity. InputObjectTypeDefinitionNode input = new( location: null, @@ -81,78 +222,90 @@ private static InputObjectTypeDefinitionNode GenerateCreateInputType( } /// - /// This method is used to determine if a field is allowed to be sent from the client in a Create mutation (eg, id field is not settable during create). + /// This method is used to determine if a relationship field is allowed to be sent from the client in a Create mutation + /// for a relational database. If the field is a pagination field (for *:N relationships) or if we infer an object + /// definition for the field (for *:1 relationships), the field is allowed in the create input. /// /// Field to check /// The type of database to generate for /// The other named types in the schema /// true if the field is allowed, false if it is not. - private static bool FieldAllowedOnCreateInput(FieldDefinitionNode field, DatabaseType databaseType, IEnumerable definitions) + private static bool IsComplexFieldAllowedForCreateInputInRelationalDb(FieldDefinitionNode field, DatabaseType databaseType, IEnumerable definitions) { - if (IsBuiltInType(field.Type)) - { - // cosmosdb_nosql doesn't have the concept of "auto increment" for the ID field, nor does it have "auto generate" - // fields like timestap/etc. like SQL, so we're assuming that any built-in type will be user-settable - // during the create mutation - return databaseType switch - { - DatabaseType.CosmosDB_NoSQL => true, - _ => !IsAutoGeneratedField(field), - }; - } - if (QueryBuilder.IsPaginationType(field.Type.NamedType())) { - return false; + return DoesRelationalDBSupportMultipleCreate(databaseType); } HotChocolate.Language.IHasName? definition = definitions.FirstOrDefault(d => d.Name.Value == field.Type.NamedType().Name.Value); - // When creating, you don't need to provide the data for nested models, but you will for other nested types - // For cosmos, allow updating nested objects - if (definition != null && definition is ObjectTypeDefinitionNode objectType && IsModelType(objectType) && databaseType is not DatabaseType.CosmosDB_NoSQL) + if (definition != null && definition is ObjectTypeDefinitionNode objectType && IsModelType(objectType)) { - return false; + return DoesRelationalDBSupportMultipleCreate(databaseType); } - return true; + return false; } - private static InputValueDefinitionNode GenerateSimpleInputType(NameNode name, FieldDefinitionNode f) + /// + /// Helper method to check if a field in an entity(table) is a referencing field to a referenced field + /// in another entity. + /// + /// Field definition. + private static bool DoesFieldHaveReferencingFieldDirective(FieldDefinitionNode field) + { + return field.Directives.Any(d => d.Name.Value.Equals(ReferencingFieldDirectiveType.DirectiveName)); + } + + /// + /// Helper method to create input type for a scalar/column field in an entity. + /// + /// Name of the field. + /// Field definition. + /// Database type + private static InputValueDefinitionNode GenerateScalarInputType(NameNode name, FieldDefinitionNode fieldDefinition, DatabaseType databaseType) { IValueNode? defaultValue = null; - if (DefaultValueDirectiveType.TryGetDefaultValue(f, out ObjectValueNode? value)) + if (DefaultValueDirectiveType.TryGetDefaultValue(fieldDefinition, out ObjectValueNode? value)) { defaultValue = value.Fields[0].Value; } + bool isFieldNullable = defaultValue is not null || + (IsRelationalDb(databaseType) && DoesRelationalDBSupportMultipleCreate(databaseType) && DoesFieldHaveReferencingFieldDirective(fieldDefinition)); + return new( location: null, - f.Name, - new StringValueNode($"Input for field {f.Name} on type {GenerateInputTypeName(name.Value)}"), - defaultValue is not null ? f.Type.NullableType() : f.Type, + fieldDefinition.Name, + new StringValueNode($"Input for field {fieldDefinition.Name} on type {GenerateInputTypeName(name.Value)}"), + isFieldNullable ? fieldDefinition.Type.NullableType() : fieldDefinition.Type, defaultValue, new List() ); } /// - /// Generates a GraphQL Input Type value for an object type, generally one provided from the database. + /// Generates a GraphQL Input Type value for: + /// 1. An object type sourced from the relational database (for entities exposed in config), + /// 2. For source->target linking object types needed to support multiple create. /// /// Dictionary of all input types, allowing reuse where possible. /// All named GraphQL types from the schema (objects, enums, etc.) for referencing. /// Field that the input type is being generated for. - /// Name of the input type in the dictionary. - /// The GraphQL object type to create the input type for. + /// In case of relationships with M:N cardinality, typeName = type name of linking object, else typeName = type name of target entity. + /// Object type name of the target entity. + /// The GraphQL object type to create the input type for. /// Database type to generate the input type for. /// Runtime configuration information for entities. /// A GraphQL input type value. - private static InputValueDefinitionNode GetComplexInputType( + private static InputValueDefinitionNode GenerateComplexInputTypeForRelationalDb( + string entityName, Dictionary inputs, IEnumerable definitions, FieldDefinitionNode field, string typeName, - ObjectTypeDefinitionNode otdn, + NameNode targetObjectTypeName, + ObjectTypeDefinitionNode objectTypeDefinitionNode, DatabaseType databaseType, RuntimeEntities entities) { @@ -160,20 +313,93 @@ private static InputValueDefinitionNode GetComplexInputType( NameNode inputTypeName = GenerateInputTypeName(typeName); if (!inputs.ContainsKey(inputTypeName)) { - node = GenerateCreateInputType(inputs, otdn, field.Type.NamedType().Name, definitions, databaseType, entities); + node = GenerateCreateInputTypeForRelationalDb( + inputs, + objectTypeDefinitionNode, + entityName, + new NameNode(typeName), + targetObjectTypeName, + definitions, + databaseType, + entities); + } + else + { + node = inputs[inputTypeName]; + } + + return GetComplexInputType(field, databaseType, node, inputTypeName); + } + + /// + /// Generates a GraphQL Input Type value for an object type, provided from the non-relational database. + /// + /// Dictionary of all input types, allowing reuse where possible. + /// All named GraphQL types from the schema (objects, enums, etc.) for referencing. + /// Field that the input type is being generated for. + /// Type name of the related entity. + /// The GraphQL object type to create the input type for. + /// Database type to generate the input type for. + /// A GraphQL input type value. + private static InputValueDefinitionNode GenerateComplexInputTypeForNonRelationalDb( + Dictionary inputs, + IEnumerable definitions, + FieldDefinitionNode field, + string typeName, + ObjectTypeDefinitionNode objectTypeDefinitionNode, + DatabaseType databaseType) + { + InputObjectTypeDefinitionNode node; + NameNode inputTypeName = GenerateInputTypeName(typeName); + if (!inputs.ContainsKey(inputTypeName)) + { + node = GenerateCreateInputTypeForNonRelationalDb( + inputs: inputs, + objectTypeDefinitionNode: objectTypeDefinitionNode, + name: field.Type.NamedType().Name, + definitions: definitions, + databaseType: databaseType); } else { node = inputs[inputTypeName]; } - ITypeNode type = new NamedTypeNode(node.Name); + return GetComplexInputType(field, databaseType, node, inputTypeName); + } + /// + /// Creates and returns InputValueDefinitionNode for a field representing a related entity in it's + /// parent's InputObjectTypeDefinitionNode. + /// + /// Related field's definition. + /// Database type. + /// Related field's InputObjectTypeDefinitionNode. + /// Input type name of the parent entity. + /// + private static InputValueDefinitionNode GetComplexInputType( + FieldDefinitionNode relatedFieldDefinition, + DatabaseType databaseType, + InputObjectTypeDefinitionNode relatedFieldInputObjectTypeDefinition, + NameNode parentInputTypeName) + { + ITypeNode type = new NamedTypeNode(relatedFieldInputObjectTypeDefinition.Name); + if (IsRelationalDb(databaseType) && DoesRelationalDBSupportMultipleCreate(databaseType)) + { + if (RelationshipDirectiveType.Cardinality(relatedFieldDefinition) is Cardinality.Many) + { + // For *:N relationships, we need to create a list type. + type = GenerateListType(type, relatedFieldDefinition.Type.InnerType()); + } + + // Since providing input for a relationship field is optional, the type should be nullable. + type = (INullableTypeNode)type; + } // For a type like [Bar!]! we have to first unpack the outer non-null - if (field.Type.IsNonNullType()) + else if (relatedFieldDefinition.Type.IsNonNullType()) { // The innerType is the raw List, scalar or object type without null settings - ITypeNode innerType = field.Type.InnerType(); + ITypeNode innerType = relatedFieldDefinition.Type.InnerType(); if (innerType.IsListType()) { @@ -183,21 +409,34 @@ private static InputValueDefinitionNode GetComplexInputType( // Wrap the input with non-null to match the field definition type = new NonNullTypeNode((INullableTypeNode)type); } - else if (field.Type.IsListType()) + else if (relatedFieldDefinition.Type.IsListType()) { - type = GenerateListType(type, field.Type); + type = GenerateListType(type, relatedFieldDefinition.Type); } return new( location: null, - field.Name, - new StringValueNode($"Input for field {field.Name} on type {inputTypeName}"), - type, + name: relatedFieldDefinition.Name, + description: new StringValueNode($"Input for field {relatedFieldDefinition.Name} on type {parentInputTypeName}"), + type: type, defaultValue: null, - field.Directives + directives: relatedFieldDefinition.Directives ); } + /// + /// Helper method to determine if the relationship defined between the source entity and a particular target entity is an M:N relationship. + /// + /// Source entity. + /// Relationship name. + /// true if the relationship between source and target entities has a cardinality of M:N. + private static bool IsMToNRelationship(Entity sourceEntity, string relationshipName) + { + return sourceEntity.Relationships is not null && + sourceEntity.Relationships.TryGetValue(relationshipName, out EntityRelationship? relationshipInfo) && + !string.IsNullOrWhiteSpace(relationshipInfo.LinkingObject); + } + private static ITypeNode GenerateListType(ITypeNode type, ITypeNode fieldType) { // Look at the inner type of the list type, eg: [Bar]'s inner type is Bar @@ -212,13 +451,13 @@ private static ITypeNode GenerateListType(ITypeNode type, ITypeNode fieldType) /// /// Name of the entity /// InputTypeName - private static NameNode GenerateInputTypeName(string typeName) + public static NameNode GenerateInputTypeName(string typeName) { return new($"{EntityActionOperation.Create}{typeName}Input"); } /// - /// Generate the `create` mutation field for the GraphQL mutations for a given Object Definition + /// Generate the `create` point/multiple mutation fields for the GraphQL mutations for a given Object Definition /// ReturnEntityName can be different from dbEntityName in cases where user wants summary results returned (through the DBOperationResult entity) /// as opposed to full entity. /// @@ -232,7 +471,7 @@ private static NameNode GenerateInputTypeName(string typeName) /// Name of type to be returned by the mutation. /// Collection of role names allowed for action, to be added to authorize directive. /// A GraphQL field definition named create*EntityName* to be attached to the Mutations type in the GraphQL schema. - public static FieldDefinitionNode Build( + public static IEnumerable Build( NameNode name, Dictionary inputs, ObjectTypeDefinitionNode objectTypeDefinitionNode, @@ -243,15 +482,30 @@ public static FieldDefinitionNode Build( string returnEntityName, IEnumerable? rolesAllowedForMutation = null) { + List createMutationNodes = new(); Entity entity = entities[dbEntityName]; - - InputObjectTypeDefinitionNode input = GenerateCreateInputType( - inputs, - objectTypeDefinitionNode, - name, - root.Definitions.Where(d => d is HotChocolate.Language.IHasName).Cast(), - databaseType, - entities); + InputObjectTypeDefinitionNode input; + if (!IsRelationalDb(databaseType)) + { + input = GenerateCreateInputTypeForNonRelationalDb( + inputs, + objectTypeDefinitionNode, + name, + root.Definitions.Where(d => d is HotChocolate.Language.IHasName).Cast(), + databaseType); + } + else + { + input = GenerateCreateInputTypeForRelationalDb( + inputs: inputs, + objectTypeDefinitionNode: objectTypeDefinitionNode, + entityName: dbEntityName, + name: name, + baseEntityName: name, + definitions: root.Definitions.Where(d => d is HotChocolate.Language.IHasName).Cast(), + databaseType: databaseType, + entities: entities); + } List fieldDefinitionNodeDirectives = new() { new(ModelDirectiveType.DirectiveName, new ArgumentNode(ModelDirectiveType.ModelNameArgument, dbEntityName)) }; @@ -264,22 +518,73 @@ public static FieldDefinitionNode Build( } string singularName = GetDefinedSingularName(name.Value, entity); - return new( + + // Create one node. + FieldDefinitionNode createOneNode = new( location: null, - new NameNode($"create{singularName}"), - new StringValueNode($"Creates a new {singularName}"), - new List { - new InputValueDefinitionNode( - location : null, - new NameNode(INPUT_ARGUMENT_NAME), - new StringValueNode($"Input representing all the fields for creating {name}"), - new NonNullTypeNode(new NamedTypeNode(input.Name)), - defaultValue: null, - new List()) + name: new NameNode(GetPointCreateMutationNodeName(name.Value, entity)), + description: new StringValueNode($"Creates a new {singularName}"), + arguments: new List { + new( + location : null, + new NameNode(MutationBuilder.ITEM_INPUT_ARGUMENT_NAME), + new StringValueNode($"Input representing all the fields for creating {name}"), + new NonNullTypeNode(new NamedTypeNode(input.Name)), + defaultValue: null, + new List()) }, - new NamedTypeNode(returnEntityName), - fieldDefinitionNodeDirectives + type: new NamedTypeNode(returnEntityName), + directives: fieldDefinitionNodeDirectives ); + + createMutationNodes.Add(createOneNode); + + if (IsRelationalDb(databaseType) && DoesRelationalDBSupportMultipleCreate(databaseType)) + { + // Create multiple node. + FieldDefinitionNode createMultipleNode = new( + location: null, + name: new NameNode(GetMultipleCreateMutationNodeName(name.Value, entity)), + description: new StringValueNode($"Creates multiple new {GetDefinedPluralName(name.Value, entity)}"), + arguments: new List { + new( + location : null, + new NameNode(MutationBuilder.ARRAY_INPUT_ARGUMENT_NAME), + new StringValueNode($"Input representing all the fields for creating {name}"), + new ListTypeNode(new NonNullTypeNode(new NamedTypeNode(input.Name))), + defaultValue: null, + new List()) + }, + type: new NamedTypeNode(QueryBuilder.GeneratePaginationTypeName(GetDefinedSingularName(dbEntityName, entity))), + directives: fieldDefinitionNodeDirectives + ); + createMutationNodes.Add(createMultipleNode); + } + + return createMutationNodes; + } + + /// + /// Helper method to determine the name of the create one (or point create) mutation. + /// + public static string GetPointCreateMutationNodeName(string entityName, Entity entity) + { + string singularName = GetDefinedSingularName(entityName, entity); + return $"{CREATE_MUTATION_PREFIX}{singularName}"; + } + + /// + /// Helper method to determine the name of the create multiple mutation. + /// If the singular and plural graphql names for the entity match, we suffix the name with 'Multiple' suffix to indicate + /// that the mutation field is created to support insertion of multiple records in the top level entity. + /// However if the plural and singular names are different, we use the plural name to construct the mutation. + /// + public static string GetMultipleCreateMutationNodeName(string entityName, Entity entity) + { + string singularName = GetDefinedSingularName(entityName, entity); + string pluralName = GetDefinedPluralName(entityName, entity); + string mutationName = singularName.Equals(pluralName) ? $"{singularName}{CREATE_MULTIPLE_MUTATION_SUFFIX}" : pluralName; + return $"{CREATE_MUTATION_PREFIX}{mutationName}"; } } } diff --git a/src/Service.GraphQLBuilder/Mutations/MutationBuilder.cs b/src/Service.GraphQLBuilder/Mutations/MutationBuilder.cs index fe87a4e720..95e63210de 100644 --- a/src/Service.GraphQLBuilder/Mutations/MutationBuilder.cs +++ b/src/Service.GraphQLBuilder/Mutations/MutationBuilder.cs @@ -20,7 +20,8 @@ public static class MutationBuilder /// The item field's metadata is of type OperationEntityInput /// i.e. CreateBookInput /// - public const string INPUT_ARGUMENT_NAME = "item"; + public const string ITEM_INPUT_ARGUMENT_NAME = "item"; + public const string ARRAY_INPUT_ARGUMENT_NAME = "items"; /// /// Creates a DocumentNode containing FieldDefinitionNodes representing mutations @@ -47,19 +48,17 @@ public static DocumentNode Build( { string dbEntityName = ObjectTypeToEntityName(objectTypeDefinitionNode); NameNode name = objectTypeDefinitionNode.Name; - + Entity entity = entities[dbEntityName]; // For stored procedures, only one mutation is created in the schema // unlike table/views where we create one for each CUD operation. - if (entities[dbEntityName].Source.Type is EntitySourceType.StoredProcedure) + if (entity.Source.Type is EntitySourceType.StoredProcedure) { // check graphql sp config - string entityName = ObjectTypeToEntityName(objectTypeDefinitionNode); - Entity entity = entities[entityName]; bool isSPDefinedAsMutation = (entity.GraphQL.Operation ?? GraphQLOperation.Mutation) is GraphQLOperation.Mutation; if (isSPDefinedAsMutation) { - if (dbObjects is not null && dbObjects.TryGetValue(entityName, out DatabaseObject? dbObject) && dbObject is not null) + if (dbObjects is not null && dbObjects.TryGetValue(dbEntityName, out DatabaseObject? dbObject) && dbObject is not null) { AddMutationsForStoredProcedure(dbEntityName, entityPermissionsMap, name, entities, mutationFields, dbObject); } @@ -130,7 +129,9 @@ string returnEntityName switch (operation) { case EntityActionOperation.Create: - mutationFields.Add(CreateMutationBuilder.Build(name, inputs, objectTypeDefinitionNode, root, databaseType, entities, dbEntityName, returnEntityName, rolesAllowedForMutation)); + // Get the create one/many fields for the create mutation. + IEnumerable createMutationNodes = CreateMutationBuilder.Build(name, inputs, objectTypeDefinitionNode, root, databaseType, entities, dbEntityName, returnEntityName, rolesAllowedForMutation); + mutationFields.AddRange(createMutationNodes); break; case EntityActionOperation.Update: mutationFields.Add(UpdateMutationBuilder.Build(name, inputs, objectTypeDefinitionNode, root, entities, dbEntityName, databaseType, returnEntityName, rolesAllowedForMutation)); diff --git a/src/Service.GraphQLBuilder/Queries/QueryBuilder.cs b/src/Service.GraphQLBuilder/Queries/QueryBuilder.cs index 0f987b8fe3..b68eb642af 100644 --- a/src/Service.GraphQLBuilder/Queries/QueryBuilder.cs +++ b/src/Service.GraphQLBuilder/Queries/QueryBuilder.cs @@ -248,21 +248,21 @@ public static ObjectTypeDefinitionNode GenerateReturnType(NameNode name) new List(), new List(), new List { - new FieldDefinitionNode( + new( location: null, new NameNode(PAGINATION_FIELD_NAME), new StringValueNode("The list of items that matched the filter"), new List(), new NonNullTypeNode(new ListTypeNode(new NonNullTypeNode(new NamedTypeNode(name)))), new List()), - new FieldDefinitionNode( + new( location : null, new NameNode(PAGINATION_TOKEN_FIELD_NAME), new StringValueNode("A pagination token to provide to subsequent pages of a query"), new List(), new StringType().ToTypeNode(), new List()), - new FieldDefinitionNode( + new( location: null, new NameNode(HAS_NEXT_PAGE_FIELD_NAME), new StringValueNode("Indicates if there are more pages of items to return"), diff --git a/src/Service.GraphQLBuilder/Sql/SchemaConverter.cs b/src/Service.GraphQLBuilder/Sql/SchemaConverter.cs index e2119f04bb..492325b3c5 100644 --- a/src/Service.GraphQLBuilder/Sql/SchemaConverter.cs +++ b/src/Service.GraphQLBuilder/Sql/SchemaConverter.cs @@ -32,146 +32,300 @@ public static class SchemaConverter /// Roles to add to authorize directive at the object level (applies to query/read ops). /// Roles to add to authorize directive at the field level (applies to mutations). /// A GraphQL object type to be provided to a Hot Chocolate GraphQL document. - public static ObjectTypeDefinitionNode FromDatabaseObject( + public static ObjectTypeDefinitionNode GenerateObjectTypeDefinitionForDatabaseObject( string entityName, DatabaseObject databaseObject, [NotNull] Entity configEntity, RuntimeEntities entities, IEnumerable rolesAllowedForEntity, IDictionary> rolesAllowedForFields) + { + ObjectTypeDefinitionNode objectDefinitionNode; + switch (databaseObject.SourceType) + { + case EntitySourceType.StoredProcedure: + objectDefinitionNode = CreateObjectTypeDefinitionForStoredProcedure( + entityName: entityName, + databaseObject: databaseObject, + configEntity: configEntity, + rolesAllowedForEntity: rolesAllowedForEntity, + rolesAllowedForFields: rolesAllowedForFields); + break; + case EntitySourceType.Table: + case EntitySourceType.View: + objectDefinitionNode = CreateObjectTypeDefinitionForTableOrView( + entityName: entityName, + databaseObject: databaseObject, + configEntity: configEntity, + entities: entities, + rolesAllowedForEntity: rolesAllowedForEntity, + rolesAllowedForFields: rolesAllowedForFields); + break; + default: + throw new DataApiBuilderException( + message: $"The source type of entity: {entityName} is not supported", + statusCode: HttpStatusCode.ServiceUnavailable, + subStatusCode: DataApiBuilderException.SubStatusCodes.NotSupported); + } + + return objectDefinitionNode; + } + + /// + /// Helper method to create object type definition for stored procedures. + /// + /// Name of the entity in the runtime config to generate the GraphQL object type for. + /// SQL database object information. + /// Runtime config information for the table. + /// Roles to add to authorize directive at the object level (applies to query/read ops). + /// Roles to add to authorize directive at the field level (applies to mutations). + /// A GraphQL object type for the table/view to be provided to a Hot Chocolate GraphQL document. + private static ObjectTypeDefinitionNode CreateObjectTypeDefinitionForStoredProcedure( + string entityName, + DatabaseObject databaseObject, + Entity configEntity, + IEnumerable rolesAllowedForEntity, + IDictionary> rolesAllowedForFields) { Dictionary fields = new(); - List objectTypeDirectives = new(); - SourceDefinition sourceDefinition = databaseObject.SourceDefinition; - NameNode nameNode = new(value: GetDefinedSingularName(entityName, configEntity)); + SourceDefinition storedProcedureDefinition = databaseObject.SourceDefinition; // When the result set is not defined, it could be a mutation operation with no returning columns // Here we create a field called result which will be an empty array. - if (databaseObject.SourceType is EntitySourceType.StoredProcedure && ((StoredProcedureDefinition)sourceDefinition).Columns.Count == 0) + if (storedProcedureDefinition.Columns.Count == 0) { FieldDefinitionNode field = GetDefaultResultFieldForStoredProcedure(); fields.TryAdd("result", field); } - foreach ((string columnName, ColumnDefinition column) in sourceDefinition.Columns) + foreach ((string columnName, ColumnDefinition column) in storedProcedureDefinition.Columns) { List directives = new(); + // A field is added to the schema when there is atleast one role allowed to access the field. + if (rolesAllowedForFields.TryGetValue(key: columnName, out IEnumerable? roles)) + { + // Even if roles is empty, we create a field for columns returned by a stored-procedures since they only support 1 CRUD action, + // and it's possible that it might return some values during mutation operation (i.e, containing one of create/update/delete permission). + FieldDefinitionNode field = GenerateFieldForColumn(configEntity, columnName, column, directives, roles); + fields.Add(columnName, field); + } + } - if (databaseObject.SourceType is not EntitySourceType.StoredProcedure && sourceDefinition.PrimaryKey.Contains(columnName)) + // Top-level object type definition name should be singular. + // The singularPlural.Singular value is used, and if not configured, + // the top-level entity name value is used. No singularization occurs + // if the top-level entity name is already plural. + return new ObjectTypeDefinitionNode( + location: null, + name: new(value: GetDefinedSingularName(entityName, configEntity)), + description: null, + directives: GenerateObjectTypeDirectivesForEntity(entityName, configEntity, rolesAllowedForEntity), + new List(), + fields.Values.ToImmutableList()); + } + + /// + /// Helper method to create object type definition for database tables or views. + /// + /// Name of the entity in the runtime config to generate the GraphQL object type for. + /// SQL database object information. + /// Runtime config information for the table. + /// Key/Value Collection mapping entity name to the entity object, + /// currently used to lookup relationship metadata. + /// Roles to add to authorize directive at the object level (applies to query/read ops). + /// Roles to add to authorize directive at the field level (applies to mutations). + /// A GraphQL object type for the table/view to be provided to a Hot Chocolate GraphQL document. + private static ObjectTypeDefinitionNode CreateObjectTypeDefinitionForTableOrView( + string entityName, + DatabaseObject databaseObject, + Entity configEntity, + RuntimeEntities entities, + IEnumerable rolesAllowedForEntity, + IDictionary> rolesAllowedForFields) + { + Dictionary fieldDefinitionNodes = new(); + SourceDefinition sourceDefinition = databaseObject.SourceDefinition; + foreach ((string columnName, ColumnDefinition column) in sourceDefinition.Columns) + { + List directives = new(); + if (sourceDefinition.PrimaryKey.Contains(columnName)) { directives.Add(new DirectiveNode(PrimaryKeyDirectiveType.DirectiveName, new ArgumentNode("databaseType", column.SystemType.Name))); } - if (databaseObject.SourceType is not EntitySourceType.StoredProcedure && column.IsReadOnly) + if (column.IsReadOnly) { directives.Add(new DirectiveNode(AutoGeneratedDirectiveType.DirectiveName)); } - if (databaseObject.SourceType is not EntitySourceType.StoredProcedure && column.DefaultValue is not null) + if (column.DefaultValue is not null) { IValueNode arg = CreateValueNodeFromDbObjectMetadata(column.DefaultValue); directives.Add(new DirectiveNode(DefaultValueDirectiveType.DirectiveName, new ArgumentNode("value", arg))); } - // If no roles are allowed for the field, we should not include it in the schema. - // Consequently, the field is only added to schema if this conditional evaluates to TRUE. - if (rolesAllowedForFields.TryGetValue(key: columnName, out IEnumerable? roles)) + // A field is added to the ObjectTypeDefinition when: + // 1. The entity is a linking entity. A linking entity is not exposed by DAB for query/mutation but the fields are required to generate + // object definitions of directional linking entities from source to target. + // 2. The entity is not a linking entity and there is atleast one role allowed to access the field. + if (rolesAllowedForFields.TryGetValue(key: columnName, out IEnumerable? roles) || configEntity.IsLinkingEntity) { // Roles will not be null here if TryGetValue evaluates to true, so here we check if there are any roles to process. - // Since Stored-procedures only support 1 CRUD action, it's possible that stored-procedures might return some values - // during mutation operation (i.e, containing one of create/update/delete permission). - // Hence, this check is bypassed for stored-procedures. - if (roles.Count() > 0 || databaseObject.SourceType is EntitySourceType.StoredProcedure) + // This check is bypassed for linking entities for the same reason explained above. + if (configEntity.IsLinkingEntity || roles is not null && roles.Count() > 0) { - if (GraphQLUtils.CreateAuthorizationDirectiveIfNecessary( - roles, - out DirectiveNode? authZDirective)) - { - directives.Add(authZDirective!); - } - - string exposedColumnName = columnName; - if (configEntity.Mappings is not null && configEntity.Mappings.TryGetValue(key: columnName, out string? columnAlias)) - { - exposedColumnName = columnAlias; - } - - NamedTypeNode fieldType = new(GetGraphQLTypeFromSystemType(column.SystemType)); - FieldDefinitionNode field = new( - location: null, - new(exposedColumnName), - description: null, - new List(), - column.IsNullable ? fieldType : new NonNullTypeNode(fieldType), - directives); - - fields.Add(columnName, field); + FieldDefinitionNode field = GenerateFieldForColumn(configEntity, columnName, column, directives, roles); + fieldDefinitionNodes.Add(columnName, field); } } } - if (configEntity.Relationships is not null) + // A linking entity is not exposed in the runtime config file but is used by DAB to support multiple mutations on entities with M:N relationship. + // Hence we don't need to process relationships for the linking entity itself. + if (!configEntity.IsLinkingEntity) { - foreach ((string relationshipName, EntityRelationship relationship) in configEntity.Relationships) + // For an entity exposed in the config, process the relationships (if there are any) + // sequentially and generate fields for them - to be added to the entity's ObjectTypeDefinition at the end. + if (configEntity.Relationships is not null) { - // Generate the field that represents the relationship to ObjectType, so you can navigate through it - // and walk the graph - string targetEntityName = relationship.TargetEntity.Split('.').Last(); - Entity referencedEntity = entities[targetEntityName]; - - bool isNullableRelationship = FindNullabilityOfRelationship(entityName, databaseObject, targetEntityName); - - INullableTypeNode targetField = relationship.Cardinality switch + foreach ((string relationshipName, EntityRelationship relationship) in configEntity.Relationships) { - Cardinality.One => - new NamedTypeNode(GetDefinedSingularName(targetEntityName, referencedEntity)), - Cardinality.Many => - new NamedTypeNode(QueryBuilder.GeneratePaginationTypeName(GetDefinedSingularName(targetEntityName, referencedEntity))), - _ => - throw new DataApiBuilderException( - message: "Specified cardinality isn't supported", - statusCode: HttpStatusCode.InternalServerError, - subStatusCode: DataApiBuilderException.SubStatusCodes.GraphQLMapping), - }; - - FieldDefinitionNode relationshipField = new( - location: null, - new NameNode(relationshipName), - description: null, - new List(), - isNullableRelationship ? targetField : new NonNullTypeNode(targetField), - new List { - new(RelationshipDirectiveType.DirectiveName, - new ArgumentNode("target", GetDefinedSingularName(targetEntityName, referencedEntity)), - new ArgumentNode("cardinality", relationship.Cardinality.ToString())) - }); - - fields.Add(relationshipField.Name.Value, relationshipField); + FieldDefinitionNode relationshipField = GenerateFieldForRelationship( + entityName, + databaseObject, + entities, + relationshipName, + relationship); + fieldDefinitionNodes.Add(relationshipField.Name.Value, relationshipField); + } } } - objectTypeDirectives.Add(new(ModelDirectiveType.DirectiveName, new ArgumentNode("name", entityName))); - - if (GraphQLUtils.CreateAuthorizationDirectiveIfNecessary( - rolesAllowedForEntity, - out DirectiveNode? authorizeDirective)) - { - objectTypeDirectives.Add(authorizeDirective!); - } - // Top-level object type definition name should be singular. // The singularPlural.Singular value is used, and if not configured, // the top-level entity name value is used. No singularization occurs // if the top-level entity name is already plural. return new ObjectTypeDefinitionNode( location: null, - name: nameNode, + name: new(value: GetDefinedSingularName(entityName, configEntity)), description: null, - objectTypeDirectives, + directives: GenerateObjectTypeDirectivesForEntity(entityName, configEntity, rolesAllowedForEntity), new List(), - fields.Values.ToImmutableList()); + fieldDefinitionNodes.Values.ToImmutableList()); + } + + /// + /// Helper method to generate the FieldDefinitionNode for a column in a table/view or a result set field in a stored-procedure. + /// + /// Entity's definition (to which the column belongs). + /// Backing column name. + /// Column definition. + /// List of directives to be added to the column's field definition. + /// List of roles having read permission on the column (for tables/views) or execute permission for stored-procedure. + /// Generated field definition node for the column to be used in the entity's object type definition. + private static FieldDefinitionNode GenerateFieldForColumn(Entity configEntity, string columnName, ColumnDefinition column, List directives, IEnumerable? roles) + { + if (GraphQLUtils.CreateAuthorizationDirectiveIfNecessary( + roles, + out DirectiveNode? authZDirective)) + { + directives.Add(authZDirective!); + } + + string exposedColumnName = columnName; + if (configEntity.Mappings is not null && configEntity.Mappings.TryGetValue(key: columnName, out string? columnAlias)) + { + exposedColumnName = columnAlias; + } + + NamedTypeNode fieldType = new(GetGraphQLTypeFromSystemType(column.SystemType)); + FieldDefinitionNode field = new( + location: null, + new(exposedColumnName), + description: null, + new List(), + column.IsNullable ? fieldType : new NonNullTypeNode(fieldType), + directives); + return field; + } + + /// + /// Helper method to generate field for a relationship for an entity. These relationship fields are populated with relationship directive + /// which stores the (cardinality, target entity) for the relationship. This enables nested queries/multiple mutations on the relationship fields. + /// + /// While processing the relationship, it helps in keeping track of fields from the source entity which hold foreign key references to the target entity. + /// + /// Name of the entity in the runtime config to generate the GraphQL object type for. + /// SQL database object information. + /// Key/Value Collection mapping entity name to the entity object, currently used to lookup relationship metadata. + /// Name of the relationship. + /// Relationship data. + private static FieldDefinitionNode GenerateFieldForRelationship( + string entityName, + DatabaseObject databaseObject, + RuntimeEntities entities, + string relationshipName, + EntityRelationship relationship) + { + // Generate the field that represents the relationship to ObjectType, so you can navigate through it + // and walk the graph. + string targetEntityName = relationship.TargetEntity.Split('.').Last(); + Entity referencedEntity = entities[targetEntityName]; + bool isNullableRelationship = FindNullabilityOfRelationship(entityName, databaseObject, targetEntityName); + + INullableTypeNode targetField = relationship.Cardinality switch + { + Cardinality.One => + new NamedTypeNode(GetDefinedSingularName(targetEntityName, referencedEntity)), + Cardinality.Many => + new NamedTypeNode(QueryBuilder.GeneratePaginationTypeName(GetDefinedSingularName(targetEntityName, referencedEntity))), + _ => + throw new DataApiBuilderException( + message: "Specified cardinality isn't supported", + statusCode: HttpStatusCode.InternalServerError, + subStatusCode: DataApiBuilderException.SubStatusCodes.GraphQLMapping), + }; + + FieldDefinitionNode relationshipField = new( + location: null, + new NameNode(relationshipName), + description: null, + new List(), + isNullableRelationship ? targetField : new NonNullTypeNode(targetField), + new List { + new(RelationshipDirectiveType.DirectiveName, + new ArgumentNode("target", GetDefinedSingularName(targetEntityName, referencedEntity)), + new ArgumentNode("cardinality", relationship.Cardinality.ToString())) + }); + + return relationshipField; + } + + /// + /// Helper method to generate the list of directives for an entity's object type definition. + /// Generates and returns the authorize and model directives to be later added to the object's definition. + /// + /// Name of the entity for whose object type definition, the list of directives are to be created. + /// Entity definition. + /// Roles to add to authorize directive at the object level (applies to query/read ops). + /// List of directives for the object definition of the entity. + private static List GenerateObjectTypeDirectivesForEntity(string entityName, Entity configEntity, IEnumerable rolesAllowedForEntity) + { + List objectTypeDirectives = new(); + if (!configEntity.IsLinkingEntity) + { + objectTypeDirectives.Add(new(ModelDirectiveType.DirectiveName, new ArgumentNode("name", entityName))); + if (GraphQLUtils.CreateAuthorizationDirectiveIfNecessary( + rolesAllowedForEntity, + out DirectiveNode? authorizeDirective)) + { + objectTypeDirectives.Add(authorizeDirective!); + } + } + + return objectTypeDirectives; } /// diff --git a/src/Service.Tests/Authorization/GraphQL/GraphQLMutationAuthorizationTests.cs b/src/Service.Tests/Authorization/GraphQL/GraphQLMutationAuthorizationTests.cs index c2ffdcc90e..a9355796ac 100644 --- a/src/Service.Tests/Authorization/GraphQL/GraphQLMutationAuthorizationTests.cs +++ b/src/Service.Tests/Authorization/GraphQL/GraphQLMutationAuthorizationTests.cs @@ -69,7 +69,7 @@ public void MutationFields_AuthorizationEvaluation(bool isAuthorized, string[] c Dictionary parameters = new() { - { MutationBuilder.INPUT_ARGUMENT_NAME, mutationInputRaw } + { MutationBuilder.ITEM_INPUT_ARGUMENT_NAME, mutationInputRaw } }; Dictionary middlewareContextData = new() diff --git a/src/Service.Tests/DatabaseSchema-MsSql.sql b/src/Service.Tests/DatabaseSchema-MsSql.sql index 0c8d9c4a5c..4c44c27459 100644 --- a/src/Service.Tests/DatabaseSchema-MsSql.sql +++ b/src/Service.Tests/DatabaseSchema-MsSql.sql @@ -109,6 +109,7 @@ CREATE TABLE reviews( CREATE TABLE book_author_link( book_id int NOT NULL, author_id int NOT NULL, + royalty_percentage float DEFAULT 0 NULL, PRIMARY KEY(book_id, author_id) ); diff --git a/src/Service.Tests/GraphQLBuilder/MsSqlMultipleMutationBuilderTests.cs b/src/Service.Tests/GraphQLBuilder/MsSqlMultipleMutationBuilderTests.cs new file mode 100644 index 0000000000..942c8b4ade --- /dev/null +++ b/src/Service.Tests/GraphQLBuilder/MsSqlMultipleMutationBuilderTests.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Azure.DataApiBuilder.Service.Tests.GraphQLBuilder +{ + [TestClass, TestCategory(TestCategory.MSSQL)] + public class MsSqlMultipleMutationBuilderTests : MultipleMutationBuilderTests + { + [ClassInitialize] + public static async Task SetupAsync(TestContext context) + { + databaseEngine = TestCategory.MSSQL; + await InitializeAsync(); + } + } +} diff --git a/src/Service.Tests/GraphQLBuilder/MultipleMutationBuilderTests.cs b/src/Service.Tests/GraphQLBuilder/MultipleMutationBuilderTests.cs new file mode 100644 index 0000000000..714a80c4d2 --- /dev/null +++ b/src/Service.Tests/GraphQLBuilder/MultipleMutationBuilderTests.cs @@ -0,0 +1,429 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Azure.DataApiBuilder.Auth; +using Azure.DataApiBuilder.Config; +using Azure.DataApiBuilder.Config.ObjectModel; +using Azure.DataApiBuilder.Core.Authorization; +using Azure.DataApiBuilder.Core.Configurations; +using Azure.DataApiBuilder.Core.Models; +using Azure.DataApiBuilder.Core.Resolvers; +using Azure.DataApiBuilder.Core.Resolvers.Factories; +using Azure.DataApiBuilder.Core.Services; +using Azure.DataApiBuilder.Core.Services.Cache; +using Azure.DataApiBuilder.Core.Services.MetadataProviders; +using Azure.DataApiBuilder.Service.GraphQLBuilder; +using Azure.DataApiBuilder.Service.GraphQLBuilder.Directives; +using Azure.DataApiBuilder.Service.GraphQLBuilder.Mutations; +using HotChocolate.Language; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using ZiggyCreatures.Caching.Fusion; +using static Azure.DataApiBuilder.Service.GraphQLBuilder.GraphQLNaming; + +namespace Azure.DataApiBuilder.Service.Tests.GraphQLBuilder +{ + /// + /// Parent class containing tests to validate different aspects of schema generation for multiple mutations for different types of + /// relational database flavours supported by DAB. All the tests in the class validate the side effect of the GraphQL schema created + /// as a result of the execution of the InitializeAsync method. + /// + [TestClass] + public abstract class MultipleMutationBuilderTests + { + // Stores the type of database - MsSql, MySql, PgSql, DwSql. Currently multiple mutations are only supported for MsSql. + protected static string databaseEngine; + + // Stores mutation definitions for entities. + private static IEnumerable _mutationDefinitions; + + // Stores object definitions for entities. + private static IEnumerable _objectDefinitions; + + // Runtime config instance. + private static RuntimeConfig _runtimeConfig; + + #region Multiple Create tests + + /// + /// Test to validate that we don't expose the object definitions inferred for linking entity/table to the end user as that is an information + /// leak. These linking object definitions are only used to generate the final source->target linking object definitions for entities + /// having an M:N relationship between them. + /// + [TestMethod] + public void ValidateAbsenceOfLinkingObjectDefinitionsInObjectsNodeForMNRelationships() + { + // Name of the source entity for which the configuration is provided in the config. + string sourceEntityName = "Book"; + + // Name of the target entity which is related to the source entity via a relationship defined in the 'relationships' + // section in the configuration of the source entity. + string targetEntityName = "Author"; + string linkingEntityName = GraphQLUtils.GenerateLinkingEntityName(sourceEntityName, targetEntityName); + ObjectTypeDefinitionNode linkingObjectTypeDefinitionNode = GetObjectTypeDefinitionNode(linkingEntityName); + + // Validate absence of linking object for Book->Author M:N relationship. + // The object definition being null here implies that the object definition is not exposed in the objects node. + Assert.IsNull(linkingObjectTypeDefinitionNode); + } + + /// + /// Test to validate the functionality of GraphQLSchemaCreator.GenerateSourceTargetLinkingObjectDefinitions() and to ensure that + /// we create a source -> target linking object definition for every pair of (source, target) entities which + /// are related via an M:N relationship. + /// + [TestMethod] + public void ValidatePresenceOfSourceTargetLinkingObjectDefinitionsInObjectsNodeForMNRelationships() + { + // Name of the source entity for which the configuration is provided in the config. + string sourceEntityName = "Book"; + + // Name of the target entity which is related to the source entity via a relationship defined in the 'relationships' + // section in the configuration of the source entity. + string targetEntityName = "Author"; + string sourceTargetLinkingNodeName = GenerateLinkingNodeName( + GetDefinedSingularName(sourceEntityName, _runtimeConfig.Entities[sourceEntityName]), + GetDefinedSingularName(targetEntityName, _runtimeConfig.Entities[targetEntityName])); + ObjectTypeDefinitionNode sourceTargetLinkingObjectTypeDefinitionNode = GetObjectTypeDefinitionNode(sourceTargetLinkingNodeName); + + // Validate presence of source->target linking object for Book->Author M:N relationship. + Assert.IsNotNull(sourceTargetLinkingObjectTypeDefinitionNode); + } + + /// + /// Test to validate that we add a referencing field directive to the list of directives for every column in an entity/table, + /// which is a referencing field to another field in any entity in the config. + /// + [TestMethod] + public void ValidatePresenceOfOneReferencingFieldDirectiveOnReferencingColumns() + { + // Name of the referencing entity. + string referencingEntityName = "Book"; + + // List of referencing columns. + string[] referencingColumns = new string[] { "publisher_id" }; + ObjectTypeDefinitionNode objectTypeDefinitionNode = GetObjectTypeDefinitionNode( + GetDefinedSingularName( + entityName: referencingEntityName, + configEntity: _runtimeConfig.Entities[referencingEntityName])); + List fieldsInObjectDefinitionNode = objectTypeDefinitionNode.Fields.ToList(); + foreach (string referencingColumn in referencingColumns) + { + int indexOfReferencingField = fieldsInObjectDefinitionNode.FindIndex((field => field.Name.Value.Equals(referencingColumn))); + FieldDefinitionNode referencingFieldDefinition = fieldsInObjectDefinitionNode[indexOfReferencingField]; + int countOfReferencingFieldDirectives = referencingFieldDefinition.Directives.Where(directive => directive.Name.Value == ReferencingFieldDirectiveType.DirectiveName).Count(); + // The presence of 1 referencing field directive indicates: + // 1. The foreign key dependency was successfully inferred from the metadata. + // 2. The referencing field directive was added only once. When a relationship between two entities is defined in the configuration of both the entities, + // we want to ensure that we don't unnecessarily add the referencing field directive twice for the referencing fields. + Assert.AreEqual(1, countOfReferencingFieldDirectives); + } + } + + /// + /// Test to validate that we don't erroneously add a referencing field directive to the list of directives for every column in an entity/table, + /// which is not a referencing field to another field in any entity in the config. + /// + [TestMethod] + public void ValidateAbsenceOfReferencingFieldDirectiveOnNonReferencingColumns() + { + // Name of the referencing entity. + string referencingEntityName = "stocks_price"; + + // List of expected referencing columns. + HashSet expectedReferencingColumns = new() { "categoryid", "pieceid" }; + ObjectTypeDefinitionNode actualObjectTypeDefinitionNode = GetObjectTypeDefinitionNode( + GetDefinedSingularName( + entityName: referencingEntityName, + configEntity: _runtimeConfig.Entities[referencingEntityName])); + List actualFieldsInObjectDefinitionNode = actualObjectTypeDefinitionNode.Fields.ToList(); + foreach (FieldDefinitionNode fieldInObjectDefinitionNode in actualFieldsInObjectDefinitionNode) + { + if (!expectedReferencingColumns.Contains(fieldInObjectDefinitionNode.Name.Value)) + { + int countOfReferencingFieldDirectives = fieldInObjectDefinitionNode.Directives.Where(directive => directive.Name.Value == ReferencingFieldDirectiveType.DirectiveName).Count(); + Assert.AreEqual(0, countOfReferencingFieldDirectives, message: "Scalar fields should not have referencing field directives."); + } + } + } + + /// + /// Test to validate that both create one, and create multiple mutations are created for entities. + /// + [TestMethod] + public void ValidateCreationOfPointAndMultipleCreateMutations() + { + string entityName = "Publisher"; + string createOneMutationName = CreateMutationBuilder.GetPointCreateMutationNodeName(entityName, _runtimeConfig.Entities[entityName]); + string createMultipleMutationName = CreateMutationBuilder.GetMultipleCreateMutationNodeName(entityName, _runtimeConfig.Entities[entityName]); + + ObjectTypeDefinitionNode mutationObjectDefinition = (ObjectTypeDefinitionNode)_mutationDefinitions.FirstOrDefault(d => d.Name.Value == "Mutation"); + + // The index of create one mutation not being equal to -1 indicates that we successfully created the mutation. + int indexOfCreateOneMutationField = mutationObjectDefinition.Fields.ToList().FindIndex(f => f.Name.Value.Equals(createOneMutationName)); + Assert.AreNotEqual(-1, indexOfCreateOneMutationField); + + // The index of create multiple mutation not being equal to -1 indicates that we successfully created the mutation. + int indexOfCreateMultipleMutationField = mutationObjectDefinition.Fields.ToList().FindIndex(f => f.Name.Value.Equals(createMultipleMutationName)); + Assert.AreNotEqual(-1, indexOfCreateMultipleMutationField); + } + + /// + /// Test to validate that in addition to column fields, relationship fields are also processed for creating the 'create' input object types. + /// This test validates that in the create'' input object type for the entity: + /// 1. A relationship field is created for every relationship defined in the 'relationships' section of the entity. + /// 2. The type of the relationship field (which represents input for the target entity) is nullable. + /// This ensures that providing input for relationship fields is optional. + /// 3. For relationships with cardinality (for target entity) as 'Many', the relationship field type is a list type - + /// to allow creating multiple records in the target entity. For relationships with cardinality 'One', + /// the relationship field type should not be a list type (and hence should be an object type). + /// + [TestMethod] + public void ValidateRelationshipFieldsInInputType() + { + string entityName = "Book"; + Entity entity = _runtimeConfig.Entities[entityName]; + NameNode inputTypeName = CreateMutationBuilder.GenerateInputTypeName(GetDefinedSingularName(entityName, entity)); + InputObjectTypeDefinitionNode inputObjectTypeDefinition = (InputObjectTypeDefinitionNode)_mutationDefinitions.FirstOrDefault(d => d.Name.Value.Equals(inputTypeName.Value)); + List inputFields = inputObjectTypeDefinition.Fields.ToList(); + HashSet inputFieldNames = new(inputObjectTypeDefinition.Fields.Select(field => field.Name.Value)); + foreach ((string relationshipName, EntityRelationship relationship) in entity.Relationships) + { + // Assert that the input type for the entity contains a field for the relationship. + Assert.AreEqual(true, inputFieldNames.Contains(relationshipName)); + + int indexOfRelationshipField = inputFields.FindIndex(field => field.Name.Value.Equals(relationshipName)); + InputValueDefinitionNode inputValueDefinitionNode = inputFields[indexOfRelationshipField]; + + // Assert that the field should be of nullable type as providing input for relationship fields is optional. + Assert.AreEqual(true, !inputValueDefinitionNode.Type.IsNonNullType()); + if (relationship.Cardinality is Cardinality.Many) + { + // For relationship with cardinality as 'Many', assert that we create a list input type. + Assert.AreEqual(true, inputValueDefinitionNode.Type.IsListType()); + } + else + { + // For relationship with cardinality as 'One', assert that we don't create a list type, + // but an object type. + Assert.AreEqual(false, inputValueDefinitionNode.Type.IsListType()); + } + } + } + + /// + /// Test to validate that for entities having an M:N relationship between them, we create a source->target linking input type. + /// + [TestMethod] + public void ValidateCreationOfSourceTargetLinkingInputForMNRelationship() + { + // Name of the source entity for which the configuration is provided in the config. + string sourceEntityName = "Book"; + + // Name of the target entity which is related to the source entity via a relationship defined in the 'relationships' + // section in the configuration of the source entity. + string targetEntityName = "Author"; + + NameNode inputTypeNameForBook = CreateMutationBuilder.GenerateInputTypeName(GetDefinedSingularName( + sourceEntityName, + _runtimeConfig.Entities[sourceEntityName])); + InputObjectTypeDefinitionNode inputObjectTypeDefinition = (InputObjectTypeDefinitionNode)_mutationDefinitions.FirstOrDefault(d => d.Name.Value.Equals(inputTypeNameForBook.Value)); + NameNode inputTypeName = CreateMutationBuilder.GenerateInputTypeName(GenerateLinkingNodeName( + GetDefinedSingularName(sourceEntityName, _runtimeConfig.Entities[sourceEntityName]), + GetDefinedSingularName(targetEntityName, _runtimeConfig.Entities[targetEntityName]))); + List inputFields = inputObjectTypeDefinition.Fields.ToList(); + int indexOfRelationshipField = inputFields.FindIndex(field => field.Type.InnerType().NamedType().Name.Value.Equals(inputTypeName.Value)); + + // Validate creation of source->target linking input object for Book->Author M:N relationship + Assert.AreNotEqual(-1, indexOfRelationshipField); + } + + /// + /// Test to validate that the linking input types generated for a source->target relationship contains input fields for: + /// 1. All the fields belonging to the target entity, and + /// 2. All the non-relationship fields in the linking entity. + /// + [TestMethod] + public void ValidateInputForMNRelationship() + { + // Name of the source entity for which the configuration is provided in the config. + string sourceEntityName = "Book"; + + // Name of the target entity which is related to the source entity via a relationship defined in the 'relationships' + // section in the configuration of the source entity. + string targetEntityName = "Author"; + string linkingObjectFieldName = "royalty_percentage"; + string sourceNodeName = GetDefinedSingularName(sourceEntityName, _runtimeConfig.Entities[sourceEntityName]); + string targetNodeName = GetDefinedSingularName(targetEntityName, _runtimeConfig.Entities[targetEntityName]); + + // Get input object definition for target entity. + NameNode targetInputTypeName = CreateMutationBuilder.GenerateInputTypeName(targetNodeName); + InputObjectTypeDefinitionNode targetInputObjectTypeDefinitionNode = (InputObjectTypeDefinitionNode)_mutationDefinitions.FirstOrDefault(d => d.Name.Value.Equals(targetInputTypeName.Value)); + + // Get input object definition for source->target linking node. + NameNode sourceTargetLinkingInputTypeName = CreateMutationBuilder.GenerateInputTypeName(GenerateLinkingNodeName(sourceNodeName, targetNodeName)); + InputObjectTypeDefinitionNode sourceTargetLinkingInputObjectTypeDefinition = (InputObjectTypeDefinitionNode)_mutationDefinitions.FirstOrDefault(d => d.Name.Value.Equals(sourceTargetLinkingInputTypeName.Value)); + + // Collect all input field names in the source->target linking node input object definition. + HashSet inputFieldNamesInSourceTargetLinkingInput = new(sourceTargetLinkingInputObjectTypeDefinition.Fields.Select(field => field.Name.Value)); + + // Assert that all the fields from the target input definition are present in the source->target linking input definition. + foreach (InputValueDefinitionNode targetInputValueField in targetInputObjectTypeDefinitionNode.Fields) + { + Assert.AreEqual(true, inputFieldNamesInSourceTargetLinkingInput.Contains(targetInputValueField.Name.Value)); + } + + // Assert that the fields ('royalty_percentage') from linking object (i.e. book_author_link) is also + // present in the input fields for the source>target linking input definition. + Assert.AreEqual(true, inputFieldNamesInSourceTargetLinkingInput.Contains(linkingObjectFieldName)); + } + + /// + /// Test to validate that in the 'create' input type for an entity, all the columns from the entity which hold a foreign key reference to + /// some other entity in the config are of nullable type. Making the FK referencing columns nullable allows the user to not specify them. + /// In such a case, for a valid mutation request, the value for these referencing columns is derived from the insertion in the referenced entity. + /// + [TestMethod] + public void ValidateNullabilityOfReferencingColumnsInInputType() + { + string referencingEntityName = "Book"; + + // Relationship: books.publisher_id -> publishers.id + string[] referencingColumns = new string[] { "publisher_id" }; + Entity entity = _runtimeConfig.Entities[referencingEntityName]; + NameNode inputTypeName = CreateMutationBuilder.GenerateInputTypeName(GetDefinedSingularName(referencingEntityName, entity)); + InputObjectTypeDefinitionNode inputObjectTypeDefinition = (InputObjectTypeDefinitionNode)_mutationDefinitions.FirstOrDefault(d => d.Name.Value.Equals(inputTypeName.Value)); + List inputFields = inputObjectTypeDefinition.Fields.ToList(); + foreach (string referencingColumn in referencingColumns) + { + int indexOfReferencingColumn = inputFields.FindIndex(field => field.Name.Value.Equals(referencingColumn)); + InputValueDefinitionNode inputValueDefinitionNode = inputFields[indexOfReferencingColumn]; + + // The field should be of nullable type as providing input for referencing fields is optional. + Assert.AreEqual(true, !inputValueDefinitionNode.Type.IsNonNullType()); + } + } + #endregion + + #region Helpers + + /// + /// Given a node name (singular name for an entity), returns the object definition created for the node. + /// + private static ObjectTypeDefinitionNode GetObjectTypeDefinitionNode(string nodeName) + { + IHasName definition = _objectDefinitions.FirstOrDefault(d => d.Name.Value == nodeName); + return definition is ObjectTypeDefinitionNode objectTypeDefinitionNode ? objectTypeDefinitionNode : null; + } + #endregion + + #region Test setup + + /// + /// Initializes the class variables to be used throughout the tests. + /// + public static async Task InitializeAsync() + { + // Setup runtime config. + RuntimeConfigProvider runtimeConfigProvider = GetRuntimeConfigProvider(); + _runtimeConfig = runtimeConfigProvider.GetConfig(); + + // Collect object definitions for entities. + GraphQLSchemaCreator schemaCreator = await GetGQLSchemaCreator(runtimeConfigProvider); + (DocumentNode objectsNode, Dictionary inputTypes) = schemaCreator.GenerateGraphQLObjects(); + _objectDefinitions = objectsNode.Definitions.Where(d => d is IHasName).Cast(); + + // Collect mutation definitions for entities. + (_, DocumentNode mutationsNode) = schemaCreator.GenerateQueryAndMutationNodes(objectsNode, inputTypes); + _mutationDefinitions = mutationsNode.Definitions.Where(d => d is IHasName).Cast(); + } + + /// + /// Sets up and returns a runtime config provider instance. + /// + private static RuntimeConfigProvider GetRuntimeConfigProvider() + { + TestHelper.SetupDatabaseEnvironment(databaseEngine); + // Get the base config file from disk + FileSystemRuntimeConfigLoader configPath = TestHelper.GetRuntimeConfigLoader(); + return new(configPath); + } + + /// + /// Sets up and returns a GraphQL schema creator instance. + /// + private static async Task GetGQLSchemaCreator(RuntimeConfigProvider runtimeConfigProvider) + { + // Setup mock loggers. + Mock httpContextAccessor = new(); + Mock> executorLogger = new(); + Mock> metadatProviderLogger = new(); + Mock> queryEngineLogger = new(); + + // Setup mock cache and cache service. + Mock cache = new(); + DabCacheService cacheService = new(cache: cache.Object, logger: null, httpContextAccessor: httpContextAccessor.Object); + + // Setup query manager factory. + IAbstractQueryManagerFactory queryManagerfactory = new QueryManagerFactory( + runtimeConfigProvider: runtimeConfigProvider, + logger: executorLogger.Object, + contextAccessor: httpContextAccessor.Object); + + // Setup metadata provider factory. + IMetadataProviderFactory metadataProviderFactory = new MetadataProviderFactory( + runtimeConfigProvider: runtimeConfigProvider, + queryManagerFactory: queryManagerfactory, + logger: metadatProviderLogger.Object, + fileSystem: null); + + // Collecte all the metadata from the database. + await metadataProviderFactory.InitializeAsync(); + + // Setup GQL filter parser. + GQLFilterParser graphQLFilterParser = new(runtimeConfigProvider: runtimeConfigProvider, metadataProviderFactory: metadataProviderFactory); + + // Setup Authorization resolver. + IAuthorizationResolver authorizationResolver = new AuthorizationResolver( + runtimeConfigProvider: runtimeConfigProvider, + metadataProviderFactory: metadataProviderFactory); + + // Setup query engine factory. + IQueryEngineFactory queryEngineFactory = new QueryEngineFactory( + runtimeConfigProvider: runtimeConfigProvider, + queryManagerFactory: queryManagerfactory, + metadataProviderFactory: metadataProviderFactory, + cosmosClientProvider: null, + contextAccessor: httpContextAccessor.Object, + authorizationResolver: authorizationResolver, + gQLFilterParser: graphQLFilterParser, + logger: queryEngineLogger.Object, + cache: cacheService); + + // Setup mock mutation engine factory. + Mock mutationEngineFactory = new(); + + // Return the setup GraphQL schema creator instance. + return new GraphQLSchemaCreator( + runtimeConfigProvider: runtimeConfigProvider, + queryEngineFactory: queryEngineFactory, + mutationEngineFactory: mutationEngineFactory.Object, + metadataProviderFactory: metadataProviderFactory, + authorizationResolver: authorizationResolver); + } + #endregion + + #region Clean up + [TestCleanup] + public void CleanupAfterEachTest() + { + TestHelper.UnsetAllDABEnvironmentVariables(); + } + #endregion + } +} diff --git a/src/Service.Tests/GraphQLBuilder/MutationBuilderTests.cs b/src/Service.Tests/GraphQLBuilder/MutationBuilderTests.cs index b8778b30c0..2c7e3ae22c 100644 --- a/src/Service.Tests/GraphQLBuilder/MutationBuilderTests.cs +++ b/src/Service.Tests/GraphQLBuilder/MutationBuilderTests.cs @@ -124,8 +124,8 @@ type Foo @model(name:""Foo"") { ), "The type Date is not a known GraphQL type, and cannot be used in this schema." ); - Assert.AreEqual(HttpStatusCode.InternalServerError, ex.StatusCode); - Assert.AreEqual(DataApiBuilderException.SubStatusCodes.GraphQLMapping, ex.SubStatusCode); + Assert.AreEqual(HttpStatusCode.ServiceUnavailable, ex.StatusCode); + Assert.AreEqual(DataApiBuilderException.SubStatusCodes.ErrorInInitialization, ex.SubStatusCode); } [TestMethod] @@ -908,46 +908,6 @@ type Bar @model(name:""Bar""){ Assert.IsFalse(inputObj.Fields[1].Type.InnerType().IsNonNullType(), "list fields should be nullable"); } - [TestMethod] - [TestCategory("Mutation Builder - Create")] - public void CreateMutationWontCreateNestedModelsOnInput() - { - string gql = - @" -type Foo @model(name:""Foo"") { - id: ID! - baz: Baz! -} - -type Baz @model(name:""Baz"") { - id: ID! - x: String! -} - "; - - DocumentNode root = Utf8GraphQLParser.Parse(gql); - - Dictionary entityNameToDatabaseType = new() - { - { "Foo", DatabaseType.MSSQL }, - { "Baz", DatabaseType.MSSQL } - }; - DocumentNode mutationRoot = MutationBuilder.Build( - root, - entityNameToDatabaseType, - new(new Dictionary { { "Foo", GenerateEmptyEntity() }, { "Baz", GenerateEmptyEntity() } }), - entityPermissionsMap: _entityPermissions - ); - - ObjectTypeDefinitionNode query = GetMutationNode(mutationRoot); - FieldDefinitionNode field = query.Fields.First(f => f.Name.Value == $"createFoo"); - Assert.AreEqual(1, field.Arguments.Count); - - InputObjectTypeDefinitionNode argType = (InputObjectTypeDefinitionNode)mutationRoot.Definitions.First(d => d is INamedSyntaxNode node && node.Name == field.Arguments[0].Type.NamedType().Name); - Assert.AreEqual(1, argType.Fields.Count); - Assert.AreEqual("id", argType.Fields[0].Name.Value); - } - [TestMethod] [TestCategory("Mutation Builder - Create")] public void CreateMutationWillCreateNestedModelsOnInputForCosmos() @@ -1112,8 +1072,24 @@ string[] expectedNames // The permissions are setup for create, update and delete operations. // So create, update and delete mutations should get generated. - // A Check to validate that the count of mutations generated is 3. - Assert.AreEqual(3 * entityNames.Length, mutation.Fields.Count); + // A Check to validate that the count of mutations generated is 4 - + // 1. 2 Create mutations (point/many) when db supports nested created, else 1. + // 2. 1 Update mutation + // 3. 1 Delete mutation + int totalExpectedMutations = 0; + foreach ((_, DatabaseType dbType) in entityNameToDatabaseType) + { + if (GraphQLUtils.DoesRelationalDBSupportMultipleCreate(dbType)) + { + totalExpectedMutations += 4; + } + else + { + totalExpectedMutations += 3; + } + } + + Assert.AreEqual(totalExpectedMutations, mutation.Fields.Count); for (int i = 0; i < entityNames.Length; i++) { diff --git a/src/Service.Tests/GraphQLBuilder/Sql/SchemaConverterTests.cs b/src/Service.Tests/GraphQLBuilder/Sql/SchemaConverterTests.cs index 23687524e1..8d3f9851d6 100644 --- a/src/Service.Tests/GraphQLBuilder/Sql/SchemaConverterTests.cs +++ b/src/Service.Tests/GraphQLBuilder/Sql/SchemaConverterTests.cs @@ -40,7 +40,7 @@ public void EntityNameBecomesObjectName(string entityName, string expected) { DatabaseObject dbObject = new DatabaseTable() { TableDefinition = new() }; - ObjectTypeDefinitionNode od = SchemaConverter.FromDatabaseObject( + ObjectTypeDefinitionNode od = SchemaConverter.GenerateObjectTypeDefinitionForDatabaseObject( entityName, dbObject, GenerateEmptyEntity(entityName), @@ -70,7 +70,7 @@ public void ColumnNameBecomesFieldName(string columnName, string expected) DatabaseObject dbObject = new DatabaseTable() { TableDefinition = table }; - ObjectTypeDefinitionNode od = SchemaConverter.FromDatabaseObject( + ObjectTypeDefinitionNode od = SchemaConverter.GenerateObjectTypeDefinitionForDatabaseObject( "table", dbObject, GenerateEmptyEntity("table"), @@ -112,7 +112,7 @@ public void FieldNameMatchesMappedValue(bool setMappings, string backingColumnNa Entity configEntity = GenerateEmptyEntity("table") with { Mappings = mappings }; - ObjectTypeDefinitionNode od = SchemaConverter.FromDatabaseObject( + ObjectTypeDefinitionNode od = SchemaConverter.GenerateObjectTypeDefinitionForDatabaseObject( "table", dbObject, configEntity, @@ -145,7 +145,7 @@ public void PrimaryKeyColumnHasAppropriateDirective() DatabaseObject dbObject = new DatabaseTable() { TableDefinition = table }; - ObjectTypeDefinitionNode od = SchemaConverter.FromDatabaseObject( + ObjectTypeDefinitionNode od = SchemaConverter.GenerateObjectTypeDefinitionForDatabaseObject( "table", dbObject, GenerateEmptyEntity("table"), @@ -174,7 +174,7 @@ public void MultiplePrimaryKeysAllMappedWithDirectives() DatabaseObject dbObject = new DatabaseTable() { TableDefinition = table }; - ObjectTypeDefinitionNode od = SchemaConverter.FromDatabaseObject( + ObjectTypeDefinitionNode od = SchemaConverter.GenerateObjectTypeDefinitionForDatabaseObject( "table", dbObject, GenerateEmptyEntity("table"), @@ -204,7 +204,7 @@ public void MultipleColumnsAllMapped() DatabaseObject dbObject = new DatabaseTable() { TableDefinition = table }; - ObjectTypeDefinitionNode od = SchemaConverter.FromDatabaseObject( + ObjectTypeDefinitionNode od = SchemaConverter.GenerateObjectTypeDefinitionForDatabaseObject( "table", dbObject, GenerateEmptyEntity("table"), @@ -243,7 +243,7 @@ public void SystemTypeMapsToCorrectGraphQLType(Type systemType, string graphQLTy DatabaseObject dbObject = new DatabaseTable() { TableDefinition = table }; - ObjectTypeDefinitionNode od = SchemaConverter.FromDatabaseObject( + ObjectTypeDefinitionNode od = SchemaConverter.GenerateObjectTypeDefinitionForDatabaseObject( "table", dbObject, GenerateEmptyEntity("table"), @@ -270,7 +270,7 @@ public void NullColumnBecomesNullField() DatabaseObject dbObject = new DatabaseTable() { TableDefinition = table }; - ObjectTypeDefinitionNode od = SchemaConverter.FromDatabaseObject( + ObjectTypeDefinitionNode od = SchemaConverter.GenerateObjectTypeDefinitionForDatabaseObject( "table", dbObject, GenerateEmptyEntity("table"), @@ -297,7 +297,7 @@ public void NonNullColumnBecomesNonNullField() DatabaseObject dbObject = new DatabaseTable() { TableDefinition = table }; - ObjectTypeDefinitionNode od = SchemaConverter.FromDatabaseObject( + ObjectTypeDefinitionNode od = SchemaConverter.GenerateObjectTypeDefinitionForDatabaseObject( "table", dbObject, GenerateEmptyEntity("table"), @@ -368,7 +368,7 @@ public void WhenForeignKeyDefinedButNoRelationship_GraphQLWontModelIt() DatabaseObject dbObject = new DatabaseTable() { TableDefinition = table }; ObjectTypeDefinitionNode od = - SchemaConverter.FromDatabaseObject( + SchemaConverter.GenerateObjectTypeDefinitionForDatabaseObject( SOURCE_ENTITY, dbObject, configEntity, @@ -405,7 +405,7 @@ public void SingularNamingRulesDeterminedByRuntimeConfig(string entityName, stri DatabaseObject dbObject = new DatabaseTable() { TableDefinition = table }; - ObjectTypeDefinitionNode od = SchemaConverter.FromDatabaseObject( + ObjectTypeDefinitionNode od = SchemaConverter.GenerateObjectTypeDefinitionForDatabaseObject( entityName, dbObject, configEntity, @@ -438,7 +438,7 @@ public void AutoGeneratedFieldHasDirectiveIndicatingSuch() DatabaseObject dbObject = new DatabaseTable() { TableDefinition = table }; Entity configEntity = GenerateEmptyEntity("entity"); - ObjectTypeDefinitionNode od = SchemaConverter.FromDatabaseObject( + ObjectTypeDefinitionNode od = SchemaConverter.GenerateObjectTypeDefinitionForDatabaseObject( "entity", dbObject, configEntity, @@ -489,7 +489,7 @@ public void DefaultValueGetsSetOnDirective(object defaultValue, string fieldName DatabaseObject dbObject = new DatabaseTable() { TableDefinition = table }; Entity configEntity = GenerateEmptyEntity("entity"); - ObjectTypeDefinitionNode od = SchemaConverter.FromDatabaseObject( + ObjectTypeDefinitionNode od = SchemaConverter.GenerateObjectTypeDefinitionForDatabaseObject( "entity", dbObject, configEntity, @@ -533,7 +533,7 @@ public void AutoGeneratedFieldHasAuthorizeDirective(string[] rolesForField) DatabaseObject dbObject = new DatabaseTable() { TableDefinition = table }; Entity configEntity = GenerateEmptyEntity("entity"); - ObjectTypeDefinitionNode od = SchemaConverter.FromDatabaseObject( + ObjectTypeDefinitionNode od = SchemaConverter.GenerateObjectTypeDefinitionForDatabaseObject( "entity", dbObject, configEntity, @@ -573,7 +573,7 @@ public void FieldWithAnonymousAccessHasNoAuthorizeDirective(string[] rolesForFie Entity configEntity = GenerateEmptyEntity("entity"); DatabaseObject dbObject = new DatabaseTable() { TableDefinition = table }; - ObjectTypeDefinitionNode od = SchemaConverter.FromDatabaseObject( + ObjectTypeDefinitionNode od = SchemaConverter.GenerateObjectTypeDefinitionForDatabaseObject( "entity", dbObject, configEntity, @@ -615,7 +615,7 @@ public void EntityObjectTypeDefinition_AuthorizeDirectivePresence(string[] roles DatabaseObject dbObject = new DatabaseTable() { TableDefinition = table }; Entity configEntity = GenerateEmptyEntity("entity"); - ObjectTypeDefinitionNode od = SchemaConverter.FromDatabaseObject( + ObjectTypeDefinitionNode od = SchemaConverter.GenerateObjectTypeDefinitionForDatabaseObject( "entity", dbObject, configEntity, @@ -663,7 +663,7 @@ public void EntityObjectTypeDefinition_AuthorizeDirectivePresenceMixed(string[] Entity configEntity = GenerateEmptyEntity("entity"); DatabaseObject dbObject = new DatabaseTable() { TableDefinition = table }; - ObjectTypeDefinitionNode od = SchemaConverter.FromDatabaseObject( + ObjectTypeDefinitionNode od = SchemaConverter.GenerateObjectTypeDefinitionForDatabaseObject( "entity", dbObject, configEntity, @@ -776,7 +776,7 @@ private static ObjectTypeDefinitionNode GenerateObjectWithRelationship(Cardinali DatabaseObject dbObject = new DatabaseTable() { SchemaName = SCHEMA_NAME, Name = TABLE_NAME, TableDefinition = table }; - return SchemaConverter.FromDatabaseObject( + return SchemaConverter.GenerateObjectTypeDefinitionForDatabaseObject( SOURCE_ENTITY, dbObject, configEntity, new(new Dictionary() { { TARGET_ENTITY, relationshipEntity } }), diff --git a/src/Service.Tests/GraphQLBuilder/Sql/StoredProcedureBuilderTests.cs b/src/Service.Tests/GraphQLBuilder/Sql/StoredProcedureBuilderTests.cs index 50fdb327c2..2016a3d8bb 100644 --- a/src/Service.Tests/GraphQLBuilder/Sql/StoredProcedureBuilderTests.cs +++ b/src/Service.Tests/GraphQLBuilder/Sql/StoredProcedureBuilderTests.cs @@ -197,7 +197,7 @@ public static ObjectTypeDefinitionNode CreateGraphQLTypeForEntity(Entity spEntit { // Output column metadata hydration, parameter entities is used for relationship metadata handling, which is not // relevant for stored procedure tests. - ObjectTypeDefinitionNode objectTypeDefinitionNode = SchemaConverter.FromDatabaseObject( + ObjectTypeDefinitionNode objectTypeDefinitionNode = SchemaConverter.GenerateObjectTypeDefinitionForDatabaseObject( entityName: entityName, spDbObj, configEntity: spEntity, diff --git a/src/Service.Tests/ModuleInitializer.cs b/src/Service.Tests/ModuleInitializer.cs index 1c41ec1049..5e718dc3f2 100644 --- a/src/Service.Tests/ModuleInitializer.cs +++ b/src/Service.Tests/ModuleInitializer.cs @@ -35,6 +35,8 @@ public static void Init() VerifierSettings.IgnoreMember(options => options.IsCachingEnabled); // Ignore the entity IsCachingEnabled as that's unimportant from a test standpoint. VerifierSettings.IgnoreMember(entity => entity.IsCachingEnabled); + // Ignore the entity IsLinkingEntity as that's unimportant from a test standpoint. + VerifierSettings.IgnoreMember(entity => entity.IsLinkingEntity); // Ignore the UserProvidedTtlOptions. They aren't serialized to our config file, enforced by EntityCacheOptionsConverter. VerifierSettings.IgnoreMember(cacheOptions => cacheOptions.UserProvidedTtlOptions); // Ignore the CosmosDataSourceUsed as that's unimportant from a test standpoint. From 9233c419530a1165f22acb338e6f228ef780eb81 Mon Sep 17 00:00:00 2001 From: Ayush Agarwal <34566234+ayush3797@users.noreply.github.com> Date: Fri, 22 Mar 2024 04:24:54 +0530 Subject: [PATCH 6/8] Multiple-create: Authorization (#1943) ## Why make this change? Since GraphQL insertions now support nested insertions, we need to authorize entity and fields not only for the top-level entity in the insertion, but also the nested entities and fields. This PR aims to address that logic of collecting all the unique entities and fields belonging to those entities in a data structure, and then sequentially iterate over all entities and fields to check whether the given role is authorized to perform the action (here nested insertion). ## What is this change? 1. The presence of the model directive on the output type of the mutation object will be used to determine whether we are dealing with a point mutation or a nested insertion. Presence of model directive indicates we are doing a point insertion while its absence indicates the opposite. This logic is added to `SqlMutationEngine.ExecuteAsync()` method. This logic determines whether the input argument name is `item` (for point mutation) or `items` (for insert many). 2. A new method `SqlMutationEngine.AuthorizeEntityAndFieldsForMutation()` is added. The name is kept generic (instead of using 'Insertion') because the same method can be used later for nested updates as well. As the name indicates, this method iterates over all the entities and fields and does the required authorization checks. 3. The method mentioned in the point above depends on another newly added method `SqlMutationEngine.PopulateMutationFieldsToAuthorize()` whose job is to populate all the unique entities referred in the mutation and their corresponding fields into a data structure of the format: `Dictionary> entityAndFieldsToAuthorize` - where for each entry in the dictionary: -> Key represents the entity name -> Value represents the unique set of fields referenced from the entity 4. The method `SqlMutationEngine.PopulateMutationFieldsToAuthorize()` recursively calls itself for nested entities based on different criteria explained in code comments. 5. When the field in a nested mutation is a list of ObjectFieldNode (fieldName: fieldValue) or the field has an object value, the fields are added to the data structure mentioned in (3) using a newly added method: `SqlMutationEngine.ProcessObjectFieldNodesForAuthZ()` which sequentially goes over all the fields and add it to the list of fields to be authorized. Since a field might represent a relationship- and hence a nested entity, this method again calls its parent caller i.e. `SqlMutationEngine.PopulateMutationFieldsToAuthorize()`. 6. The method `SqlMutationEngine.ProcessObjectFieldNodesForAuthZ()` contains the logic to ensure that the fields belonging to linking tables are not added to the list of fields to be authorized. 7. Moved the method `GetRoleOfGraphQLRequest()` from `Cosmos`/`SqlMutationEngine` to `AuthorizationResolver`. ## How was this tested? To be added. ## Sample Request(s) 1. Config: ![image](https://github.com/Azure/data-api-builder/assets/34566234/6dafe576-123d-415c-aecd-6e6b1310512d) ![image](https://github.com/Azure/data-api-builder/assets/34566234/b91912c9-4b4e-48f8-8af1-35e993733eed) 1. Request/Response - AuthZ failure because `piecesAvailable` field is not accessible to `test_role_with_excluded_fields_on_create` role. ![image](https://github.com/Azure/data-api-builder/assets/34566234/8f16fa22-6709-44bc-93c6-96e0e21f0cc7) 2. Request/Response: Removing `piecesAvailable` field from request body leads to successful authz checks (request fails during query generation). ![image](https://github.com/Azure/data-api-builder/assets/34566234/14db82d5-4f0f-4005-8f54-c5db733ca9a9) --------- Co-authored-by: Sean Leonard --- config-generators/mssql-commands.txt | 4 + .../Authorization/AuthorizationResolver.cs | 26 + src/Core/Resolvers/CosmosMutationEngine.cs | 37 +- src/Core/Resolvers/IMutationEngine.cs | 6 +- src/Core/Resolvers/SqlMutationEngine.cs | 307 ++++++++++-- src/Service.GraphQLBuilder/GraphQLUtils.cs | 44 +- .../CreateMutationAuthorizationTests.cs | 456 ++++++++++++++++++ .../GraphQLMutationAuthorizationTests.cs | 9 +- ...tReadingRuntimeConfigForMsSql.verified.txt | 46 +- src/Service.Tests/dab-config.MsSql.json | 90 +++- 10 files changed, 941 insertions(+), 84 deletions(-) create mode 100644 src/Service.Tests/Authorization/GraphQL/CreateMutationAuthorizationTests.cs diff --git a/config-generators/mssql-commands.txt b/config-generators/mssql-commands.txt index c4af5e2f77..6b97f3ebfb 100644 --- a/config-generators/mssql-commands.txt +++ b/config-generators/mssql-commands.txt @@ -14,6 +14,7 @@ add stocks_price --config "dab-config.MsSql.json" --source stocks_price --permis update stocks_price --config "dab-config.MsSql.json" --permissions "anonymous:read" update stocks_price --config "dab-config.MsSql.json" --permissions "TestNestedFilterFieldIsNull_ColumnForbidden:read" --fields.exclude "price" update stocks_price --config "dab-config.MsSql.json" --permissions "TestNestedFilterFieldIsNull_EntityReadForbidden:create" +update stocks_price --config "dab-config.MsSql.json" --permissions "test_role_with_excluded_fields_on_create:create,read,update,delete" add Tree --config "dab-config.MsSql.json" --source trees --permissions "anonymous:create,read,update,delete" add Shrub --config "dab-config.MsSql.json" --source trees --permissions "anonymous:create,read,update,delete" --rest plants add Fungus --config "dab-config.MsSql.json" --source fungi --permissions "anonymous:create,read,update,delete" --graphql "fungus:fungi" @@ -65,6 +66,8 @@ update Publisher --config "dab-config.MsSql.json" --permissions "database_policy update Publisher --config "dab-config.MsSql.json" --permissions "database_policy_tester:update" --policy-database "@item.id ne 1234" update Publisher --config "dab-config.MsSql.json" --permissions "database_policy_tester:create" --policy-database "@item.name ne 'New publisher'" update Stock --config "dab-config.MsSql.json" --permissions "authenticated:create,read,update,delete" +update Stock --config "dab-config.MsSql.json" --permissions "test_role_with_excluded_fields_on_create:read,update,delete" +update Stock --config "dab-config.MsSql.json" --permissions "test_role_with_excluded_fields_on_create:create" --fields.exclude "piecesAvailable" update Stock --config "dab-config.MsSql.json" --rest commodities --graphql true --relationship stocks_price --target.entity stocks_price --cardinality one update Book --config "dab-config.MsSql.json" --permissions "authenticated:create,read,update,delete" update Book --config "dab-config.MsSql.json" --relationship publishers --target.entity Publisher --cardinality one @@ -112,6 +115,7 @@ update WebsiteUser --config "dab-config.MsSql.json" --permissions "authenticated update Revenue --config "dab-config.MsSql.json" --permissions "database_policy_tester:create" --policy-database "@item.revenue gt 1000" update Comic --config "dab-config.MsSql.json" --permissions "authenticated:create,read,update,delete" --rest true --graphql true --relationship myseries --target.entity series --cardinality one update series --config "dab-config.MsSql.json" --relationship comics --target.entity Comic --cardinality many +update stocks_price --config "dab-config.MsSql.json" --relationship Stock --target.entity Stock --cardinality one update Broker --config "dab-config.MsSql.json" --permissions "authenticated:create,update,read,delete" --graphql false update Tree --config "dab-config.MsSql.json" --rest true --graphql false --permissions "authenticated:create,read,update,delete" --map "species:Scientific Name,region:United State's Region" update Shrub --config "dab-config.MsSql.json" --permissions "authenticated:create,read,update,delete" --map "species:fancyName" diff --git a/src/Core/Authorization/AuthorizationResolver.cs b/src/Core/Authorization/AuthorizationResolver.cs index 93e4622f29..64785de703 100644 --- a/src/Core/Authorization/AuthorizationResolver.cs +++ b/src/Core/Authorization/AuthorizationResolver.cs @@ -13,6 +13,7 @@ using Azure.DataApiBuilder.Core.Services; using Azure.DataApiBuilder.Core.Services.MetadataProviders; using Azure.DataApiBuilder.Service.Exceptions; +using HotChocolate.Resolvers; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Primitives; @@ -213,6 +214,31 @@ public string GetDBPolicyForRequest(string entityName, string roleName, EntityAc return dbPolicy is not null ? dbPolicy : string.Empty; } + /// + /// Helper method to get the role with which the GraphQL API request was executed. + /// + /// HotChocolate context for the GraphQL request. + /// Role of the current GraphQL API request. + /// Throws exception when no client role could be inferred from the context. + public static string GetRoleOfGraphQLRequest(IMiddlewareContext context) + { + string role = string.Empty; + if (context.ContextData.TryGetValue(key: CLIENT_ROLE_HEADER, out object? value) && value is StringValues stringVals) + { + role = stringVals.ToString(); + } + + if (string.IsNullOrEmpty(role)) + { + throw new DataApiBuilderException( + message: "No ClientRoleHeader available to perform authorization.", + statusCode: HttpStatusCode.Forbidden, + subStatusCode: DataApiBuilderException.SubStatusCodes.AuthorizationCheckFailed); + } + + return role; + } + #region Helpers /// /// Method to read in data from the config class into a Dictionary for quick lookup diff --git a/src/Core/Resolvers/CosmosMutationEngine.cs b/src/Core/Resolvers/CosmosMutationEngine.cs index 4f95e4f267..7138db6223 100644 --- a/src/Core/Resolvers/CosmosMutationEngine.cs +++ b/src/Core/Resolvers/CosmosMutationEngine.cs @@ -16,7 +16,6 @@ using HotChocolate.Resolvers; using Microsoft.AspNetCore.Mvc; using Microsoft.Azure.Cosmos; -using Microsoft.Extensions.Primitives; using Newtonsoft.Json.Linq; namespace Azure.DataApiBuilder.Core.Resolvers @@ -64,7 +63,7 @@ private async Task ExecuteAsync(IMiddlewareContext context, IDictionary // If authorization fails, an exception will be thrown and request execution halts. string graphQLType = context.Selection.Field.Type.NamedType().Name.Value; string entityName = metadataProvider.GetEntityName(graphQLType); - AuthorizeMutationFields(context, queryArgs, entityName, resolver.OperationType); + AuthorizeMutation(context, queryArgs, entityName, resolver.OperationType); ItemResponse? response = resolver.OperationType switch { @@ -74,7 +73,7 @@ private async Task ExecuteAsync(IMiddlewareContext context, IDictionary _ => throw new NotSupportedException($"unsupported operation type: {resolver.OperationType}") }; - string roleName = GetRoleOfGraphQLRequest(context); + string roleName = AuthorizationResolver.GetRoleOfGraphQLRequest(context); // The presence of READ permission is checked in the current role (with which the request is executed) as well as Anonymous role. This is because, for GraphQL requests, // READ permission is inherited by other roles from Anonymous role when present. @@ -93,14 +92,13 @@ private async Task ExecuteAsync(IMiddlewareContext context, IDictionary } /// - public void AuthorizeMutationFields( + public void AuthorizeMutation( IMiddlewareContext context, IDictionary parameters, string entityName, EntityActionOperation mutationOperation) { - string role = GetRoleOfGraphQLRequest(context); - + string clientRole = AuthorizationResolver.GetRoleOfGraphQLRequest(context); List inputArgumentKeys; if (mutationOperation != EntityActionOperation.Delete) { @@ -114,9 +112,9 @@ public void AuthorizeMutationFields( bool isAuthorized = mutationOperation switch { EntityActionOperation.UpdateGraphQL => - _authorizationResolver.AreColumnsAllowedForOperation(entityName, roleName: role, operation: EntityActionOperation.Update, inputArgumentKeys), + _authorizationResolver.AreColumnsAllowedForOperation(entityName, roleName: clientRole, operation: EntityActionOperation.Update, inputArgumentKeys), EntityActionOperation.Create => - _authorizationResolver.AreColumnsAllowedForOperation(entityName, roleName: role, operation: mutationOperation, inputArgumentKeys), + _authorizationResolver.AreColumnsAllowedForOperation(entityName, roleName: clientRole, operation: mutationOperation, inputArgumentKeys), EntityActionOperation.Delete => true,// Field level authorization is not supported for delete mutations. A requestor must be authorized // to perform the delete operation on the entity to reach this point. _ => throw new DataApiBuilderException( @@ -261,29 +259,6 @@ private static async Task> HandleUpdateAsync(IDictionary - /// Helper method to get the role with which the GraphQL API request was executed. - /// - /// HotChocolate context for the GraphQL request - private static string GetRoleOfGraphQLRequest(IMiddlewareContext context) - { - string role = string.Empty; - if (context.ContextData.TryGetValue(key: AuthorizationResolver.CLIENT_ROLE_HEADER, out object? value) && value is StringValues stringVals) - { - role = stringVals.ToString(); - } - - if (string.IsNullOrEmpty(role)) - { - throw new DataApiBuilderException( - message: "No ClientRoleHeader available to perform authorization.", - statusCode: HttpStatusCode.Unauthorized, - subStatusCode: DataApiBuilderException.SubStatusCodes.AuthorizationCheckFailed); - } - - return role; - } - /// /// The method is for parsing the mutation input object with nested inner objects when input is passing inline. /// diff --git a/src/Core/Resolvers/IMutationEngine.cs b/src/Core/Resolvers/IMutationEngine.cs index da089ed199..30cb188efc 100644 --- a/src/Core/Resolvers/IMutationEngine.cs +++ b/src/Core/Resolvers/IMutationEngine.cs @@ -4,6 +4,7 @@ using System.Text.Json; using Azure.DataApiBuilder.Config.ObjectModel; using Azure.DataApiBuilder.Core.Models; +using Azure.DataApiBuilder.Service.Exceptions; using HotChocolate.Resolvers; using Microsoft.AspNetCore.Mvc; @@ -41,12 +42,13 @@ public interface IMutationEngine /// /// Authorization check on mutation fields provided in a GraphQL Mutation request. /// - /// Middleware context of the mutation + /// GraphQL request context. + /// Client role header value extracted from the middleware context of the mutation /// parameters in the mutation query. /// entity name /// mutation operation /// - public void AuthorizeMutationFields( + public void AuthorizeMutation( IMiddlewareContext context, IDictionary parameters, string entityName, diff --git a/src/Core/Resolvers/SqlMutationEngine.cs b/src/Core/Resolvers/SqlMutationEngine.cs index 298a7d5d72..63de3f0f87 100644 --- a/src/Core/Resolvers/SqlMutationEngine.cs +++ b/src/Core/Resolvers/SqlMutationEngine.cs @@ -19,11 +19,12 @@ using Azure.DataApiBuilder.Service.Exceptions; using Azure.DataApiBuilder.Service.GraphQLBuilder; using Azure.DataApiBuilder.Service.GraphQLBuilder.Mutations; +using Azure.DataApiBuilder.Service.Services; +using HotChocolate.Language; using HotChocolate.Resolvers; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Primitives; namespace Azure.DataApiBuilder.Core.Resolvers { @@ -90,11 +91,10 @@ public SqlMutationEngine( Tuple? result = null; EntityActionOperation mutationOperation = MutationBuilder.DetermineMutationOperationTypeBasedOnInputType(graphqlMutationName); + string roleName = AuthorizationResolver.GetRoleOfGraphQLRequest(context); // If authorization fails, an exception will be thrown and request execution halts. - AuthorizeMutationFields(context, parameters, entityName, mutationOperation); - - string roleName = GetRoleOfGraphQLRequest(context); + AuthorizeMutation(context, parameters, entityName, mutationOperation); // The presence of READ permission is checked in the current role (with which the request is executed) as well as Anonymous role. This is because, for GraphQL requests, // READ permission is inherited by other roles from Anonymous role when present. @@ -218,6 +218,36 @@ await PerformMutationOperation( return result; } + /// + /// Helper method to determine whether a mutation is a mutate one or mutate many operation (eg. createBook/createBooks). + /// + /// GraphQL request context. + private static bool IsPointMutation(IMiddlewareContext context) + { + IOutputType outputType = context.Selection.Field.Type; + if (outputType.TypeName().Value.Equals(GraphQLUtils.DB_OPERATION_RESULT_TYPE)) + { + // Hit when the database type is DwSql. We don't support multiple mutation for DwSql yet. + return true; + } + + ObjectType underlyingFieldType = GraphQLUtils.UnderlyingGraphQLEntityType(outputType); + bool isPointMutation; + if (GraphQLUtils.TryExtractGraphQLFieldModelName(underlyingFieldType.Directives, out string? _)) + { + isPointMutation = true; + } + else + { + // Model directive is not added to the output type of 'mutate many' mutations. + // Thus, absence of model directive here indicates that we are dealing with a 'mutate many' + // mutation like createBooks. + isPointMutation = false; + } + + return isPointMutation; + } + /// /// Converts exposed column names from the parameters provided to backing column names. /// parameters.Value is not modified. @@ -1049,41 +1079,58 @@ private void PopulateParamsFromRestRequest(Dictionary parameter } } - /// - /// Authorization check on mutation fields provided in a GraphQL Mutation request. - /// - /// - /// - /// - /// - /// - public void AuthorizeMutationFields( + /// + public void AuthorizeMutation( IMiddlewareContext context, IDictionary parameters, string entityName, EntityActionOperation mutationOperation) { - string role = GetRoleOfGraphQLRequest(context); - - List inputArgumentKeys; - if (mutationOperation != EntityActionOperation.Delete) + string inputArgumentName = MutationBuilder.ITEM_INPUT_ARGUMENT_NAME; + string clientRole = AuthorizationResolver.GetRoleOfGraphQLRequest(context); + if (mutationOperation is EntityActionOperation.Create) { - inputArgumentKeys = BaseSqlQueryStructure.GetSubArgumentNamesFromGQLMutArguments(MutationBuilder.ITEM_INPUT_ARGUMENT_NAME, parameters); + if (!IsPointMutation(context)) + { + inputArgumentName = MutationBuilder.ARRAY_INPUT_ARGUMENT_NAME; + } + + AuthorizeEntityAndFieldsForMutation(context, clientRole, entityName, mutationOperation, inputArgumentName, parameters); } else { - inputArgumentKeys = parameters.Keys.ToList(); + List inputArgumentKeys; + if (mutationOperation != EntityActionOperation.Delete) + { + inputArgumentKeys = BaseSqlQueryStructure.GetSubArgumentNamesFromGQLMutArguments(inputArgumentName, parameters); + } + else + { + inputArgumentKeys = parameters.Keys.ToList(); + } + + if (!AreFieldsAuthorizedForEntity(clientRole, entityName, mutationOperation, inputArgumentKeys)) + { + throw new DataApiBuilderException( + message: "Unauthorized due to one or more fields in this mutation.", + statusCode: HttpStatusCode.Forbidden, + subStatusCode: DataApiBuilderException.SubStatusCodes.AuthorizationCheckFailed + ); + } } + } + private bool AreFieldsAuthorizedForEntity(string clientRole, string entityName, EntityActionOperation mutationOperation, IEnumerable inputArgumentKeys) + { bool isAuthorized; // False by default. switch (mutationOperation) { case EntityActionOperation.UpdateGraphQL: - isAuthorized = _authorizationResolver.AreColumnsAllowedForOperation(entityName, roleName: role, operation: EntityActionOperation.Update, inputArgumentKeys); + isAuthorized = _authorizationResolver.AreColumnsAllowedForOperation(entityName, roleName: clientRole, operation: EntityActionOperation.Update, inputArgumentKeys); break; case EntityActionOperation.Create: - isAuthorized = _authorizationResolver.AreColumnsAllowedForOperation(entityName, roleName: role, operation: mutationOperation, inputArgumentKeys); + isAuthorized = _authorizationResolver.AreColumnsAllowedForOperation(entityName, roleName: clientRole, operation: mutationOperation, inputArgumentKeys); break; case EntityActionOperation.Execute: case EntityActionOperation.Delete: @@ -1101,37 +1148,219 @@ public void AuthorizeMutationFields( ); } - if (!isAuthorized) + return isAuthorized; + } + + /// + /// Performs authorization checks on entity level permissions and field level permissions for every entity and field + /// referenced in a GraphQL mutation for the given client role. + /// + /// Middleware context. + /// Client role header value extracted from the middleware context of the mutation + /// Top level entity name. + /// Mutation operation + /// Name of the input argument (differs based on point/multiple mutation). + /// Dictionary of key/value pairs for the argument name/value. + /// Throws exception when an authorization check fails. + private void AuthorizeEntityAndFieldsForMutation( + IMiddlewareContext context, + string clientRole, + string topLevelEntityName, + EntityActionOperation operation, + string inputArgumentName, + IDictionary parametersDictionary + ) + { + if (context.Selection.Field.Arguments.TryGetField(inputArgumentName, out IInputField? schemaForArgument)) + { + // Dictionary to store all the entities and their corresponding exposed column names referenced in the mutation. + Dictionary> entityToExposedColumns = new(); + if (parametersDictionary.TryGetValue(inputArgumentName, out object? parameters)) + { + // Get all the entity names and field names referenced in the mutation. + PopulateMutationEntityAndFieldsToAuthorize(entityToExposedColumns, schemaForArgument, topLevelEntityName, context, parameters!); + } + else + { + throw new DataApiBuilderException( + message: $"{inputArgumentName} cannot be null for mutation:{context.Selection.Field.Name.Value}.", + statusCode: HttpStatusCode.BadRequest, + subStatusCode: DataApiBuilderException.SubStatusCodes.BadRequest + ); + } + + // Perform authorization checks at field level. + foreach ((string entityNameInMutation, HashSet exposedColumnsInEntity) in entityToExposedColumns) + { + if (!AreFieldsAuthorizedForEntity(clientRole, entityNameInMutation, operation, exposedColumnsInEntity)) + { + throw new DataApiBuilderException( + message: $"Unauthorized due to one or more fields in this mutation.", + statusCode: HttpStatusCode.Forbidden, + subStatusCode: DataApiBuilderException.SubStatusCodes.AuthorizationCheckFailed + ); + } + } + } + else { throw new DataApiBuilderException( - message: "Unauthorized due to one or more fields in this mutation.", - statusCode: HttpStatusCode.Forbidden, - subStatusCode: DataApiBuilderException.SubStatusCodes.AuthorizationCheckFailed - ); + message: $"Could not interpret the schema for the input argument: {inputArgumentName}", + statusCode: HttpStatusCode.BadRequest, + subStatusCode: DataApiBuilderException.SubStatusCodes.BadRequest); } } /// - /// Helper method to get the role with which the GraphQL API request was executed. + /// Helper method to collect names of all the fields referenced from every entity in a GraphQL mutation. /// - /// HotChocolate context for the GraphQL request - private static string GetRoleOfGraphQLRequest(IMiddlewareContext context) + /// Dictionary to store all the entities and their corresponding exposed column names referenced in the mutation. + /// Schema for the input field. + /// Name of the entity. + /// Middleware Context. + /// Value for the input field. + /// 1. mutation { + /// createbook( + /// item: { + /// title: "book #1", + /// reviews: [{ content: "Good book." }, { content: "Great book." }], + /// publishers: { name: "Macmillan publishers" }, + /// authors: [{ birthdate: "1997-09-03", name: "Red house authors", author_name: "Dan Brown" }] + /// }) + /// { + /// id + /// } + /// 2. mutation { + /// createbooks( + /// items: [{ + /// title: "book #1", + /// reviews: [{ content: "Good book." }, { content: "Great book." }], + /// publishers: { name: "Macmillan publishers" }, + /// authors: [{ birthdate: "1997-09-03", name: "Red house authors", author_name: "Dan Brown" }] + /// }, + /// { + /// title: "book #2", + /// reviews: [{ content: "Awesome book." }, { content: "Average book." }], + /// publishers: { name: "Pearson Education" }, + /// authors: [{ birthdate: "1990-11-04", name: "Penguin Random House", author_name: "William Shakespeare" }] + /// }]) + /// { + /// items{ + /// id + /// title + /// } + /// } + private void PopulateMutationEntityAndFieldsToAuthorize( + Dictionary> entityToExposedColumns, + IInputField schema, + string entityName, + IMiddlewareContext context, + object parameters) { - string role = string.Empty; - if (context.ContextData.TryGetValue(key: AuthorizationResolver.CLIENT_ROLE_HEADER, out object? value) && value is StringValues stringVals) + if (parameters is List listOfObjectFieldNode) { - role = stringVals.ToString(); + // For the example createbook mutation written above, the object value for `item` is interpreted as a List i.e. + // all the fields present for item namely- title, reviews, publishers, authors are interpreted as ObjectFieldNode. + ProcessObjectFieldNodesForAuthZ( + entityToExposedColumns: entityToExposedColumns, + schemaObject: ExecutionHelper.InputObjectTypeFromIInputField(schema), + entityName: entityName, + context: context, + fieldNodes: listOfObjectFieldNode); } - - if (string.IsNullOrEmpty(role)) + else if (parameters is List listOfIValueNode) { - throw new DataApiBuilderException( - message: "No ClientRoleHeader available to perform authorization.", - statusCode: HttpStatusCode.Unauthorized, - subStatusCode: DataApiBuilderException.SubStatusCodes.AuthorizationCheckFailed); + // For the example createbooks mutation written above, the list value for `items` is interpreted as a List. + listOfIValueNode.ForEach(iValueNode => PopulateMutationEntityAndFieldsToAuthorize( + entityToExposedColumns: entityToExposedColumns, + schema: schema, + entityName: entityName, + context: context, + parameters: iValueNode)); + } + else if (parameters is ObjectValueNode objectValueNode) + { + // For the example createbook mutation written above, the node for publishers field is interpreted as an ObjectValueNode. + // Similarly the individual node (elements in the list) for the reviews, authors ListValueNode(s) are also interpreted as ObjectValueNode(s). + ProcessObjectFieldNodesForAuthZ( + entityToExposedColumns: entityToExposedColumns, + schemaObject: ExecutionHelper.InputObjectTypeFromIInputField(schema), + entityName: entityName, + context: context, + fieldNodes: objectValueNode.Fields); + } + else + { + ListValueNode listValueNode = (ListValueNode)parameters; + // For the example createbook mutation written above, the list values for reviews and authors fields are interpreted as ListValueNode. + // All the nodes in the ListValueNode are parsed one by one. + listValueNode.GetNodes().ToList().ForEach(objectValueNodeInListValueNode => PopulateMutationEntityAndFieldsToAuthorize( + entityToExposedColumns: entityToExposedColumns, + schema: schema, + entityName: entityName, + context: context, + parameters: objectValueNodeInListValueNode)); } + } - return role; + /// + /// Helper method to iterate over all the fields present in the input for the current field and add it to the dictionary + /// containing all entities and their corresponding fields. + /// + /// Dictionary to store all the entities and their corresponding exposed column names referenced in the mutation. + /// Input object type for the field. + /// Name of the entity. + /// Middleware context. + /// List of ObjectFieldNodes for the the input field. + private void ProcessObjectFieldNodesForAuthZ( + Dictionary> entityToExposedColumns, + InputObjectType schemaObject, + string entityName, + IMiddlewareContext context, + IReadOnlyList fieldNodes) + { + RuntimeConfig runtimeConfig = _runtimeConfigProvider.GetConfig(); + entityToExposedColumns.TryAdd(entityName, new HashSet()); + string dataSourceName = GraphQLUtils.GetDataSourceNameFromGraphQLContext(context, runtimeConfig); + ISqlMetadataProvider metadataProvider = _sqlMetadataProviderFactory.GetMetadataProvider(dataSourceName); + foreach (ObjectFieldNode field in fieldNodes) + { + Tuple fieldDetails = GraphQLUtils.GetFieldDetails(field.Value, context.Variables); + SyntaxKind underlyingFieldKind = fieldDetails.Item2; + + // For a column field, we do not have to recurse to process fields in the value - which is required for relationship fields. + if (GraphQLUtils.IsScalarField(underlyingFieldKind) || underlyingFieldKind is SyntaxKind.NullValue) + { + // This code block can be hit in 3 cases: + // Case 1. We are processing a column which belongs to this entity, + // + // Case 2. We are processing the fields for a linking input object. Linking input objects enable users to provide + // input for fields belonging to the target entity and the linking entity. Hence the backing column for fields + // belonging to the linking entity will not be present in the source definition of this target entity. + // We need to skip such fields belonging to linking table as we do not perform authorization checks on them. + // + // Case 3. When a relationship field is assigned a null value. Such a field also needs to be ignored. + if (metadataProvider.TryGetBackingColumn(entityName, field.Name.Value, out string? _)) + { + // Only add those fields to this entity's set of fields which belong to this entity and not the linking entity, + // i.e. for Case 1. + entityToExposedColumns[entityName].Add(field.Name.Value); + } + } + else + { + string relationshipName = field.Name.Value; + string targetEntityName = runtimeConfig.Entities![entityName].Relationships![relationshipName].TargetEntity; + + // Recurse to process fields in the value of this relationship field. + PopulateMutationEntityAndFieldsToAuthorize( + entityToExposedColumns, + schemaObject.Fields[relationshipName], + targetEntityName, + context, + fieldDetails.Item1!); + } + } } /// diff --git a/src/Service.GraphQLBuilder/GraphQLUtils.cs b/src/Service.GraphQLBuilder/GraphQLUtils.cs index 78ad23d925..bb786d0be7 100644 --- a/src/Service.GraphQLBuilder/GraphQLUtils.cs +++ b/src/Service.GraphQLBuilder/GraphQLUtils.cs @@ -7,6 +7,7 @@ using Azure.DataApiBuilder.Service.Exceptions; using Azure.DataApiBuilder.Service.GraphQLBuilder.Directives; using Azure.DataApiBuilder.Service.GraphQLBuilder.Queries; +using HotChocolate.Execution; using HotChocolate.Language; using HotChocolate.Resolvers; using HotChocolate.Types; @@ -298,7 +299,7 @@ public static string GetEntityNameFromContext(IPureResolverContext context) if (graphQLTypeName is DB_OPERATION_RESULT_TYPE) { // CUD for a mutation whose result set we do not have. Get Entity name mutation field directive. - if (GraphQLUtils.TryExtractGraphQLFieldModelName(context.Selection.Field.Directives, out string? modelName)) + if (TryExtractGraphQLFieldModelName(context.Selection.Field.Directives, out string? modelName)) { entityName = modelName; } @@ -319,7 +320,7 @@ public static string GetEntityNameFromContext(IPureResolverContext context) // if name on schema is different from name in config. // Due to possibility of rename functionality, entityName on runtimeConfig could be different from exposed schema name. - if (GraphQLUtils.TryExtractGraphQLFieldModelName(underlyingFieldType.Directives, out string? modelName)) + if (TryExtractGraphQLFieldModelName(underlyingFieldType.Directives, out string? modelName)) { entityName = modelName; } @@ -333,6 +334,45 @@ private static string GenerateDataSourceNameKeyFromPath(IPureResolverContext con return $"{context.Path.ToList()[0]}"; } + /// + /// Helper method to determine whether a field is a column or complex (relationship) field based on its syntax kind. + /// If the SyntaxKind for the field is not ObjectValue and ListValue, it implies we are dealing with a column/scalar field which + /// has an IntValue, FloatValue, StringValue, BooleanValue or an EnumValue. + /// + /// SyntaxKind of the field. + /// true if the field is a scalar field, else false. + public static bool IsScalarField(SyntaxKind fieldSyntaxKind) + { + return fieldSyntaxKind is SyntaxKind.IntValue || fieldSyntaxKind is SyntaxKind.FloatValue || + fieldSyntaxKind is SyntaxKind.StringValue || fieldSyntaxKind is SyntaxKind.BooleanValue || + fieldSyntaxKind is SyntaxKind.EnumValue; + } + + /// + /// Helper method to get the field details i.e. (field value, field kind) from the GraphQL request body. + /// If the field value is being provided as a variable in the mutation, a recursive call is made to the method + /// to get the actual value of the variable. + /// + /// Value of the field. + /// Collection of variables declared in the GraphQL mutation request. + /// A tuple containing a constant field value and the field kind. + public static Tuple GetFieldDetails(IValueNode? value, IVariableValueCollection variables) + { + if (value is null) + { + return new(null, SyntaxKind.NullValue); + } + + if (value.Kind == SyntaxKind.Variable) + { + string variableName = ((VariableNode)value).Name.Value; + IValueNode? variableValue = variables.GetVariable(variableName); + return GetFieldDetails(variableValue, variables); + } + + return new(value, value.Kind); + } + /// /// Helper method to generate the linking entity name using the source and target entity names. /// diff --git a/src/Service.Tests/Authorization/GraphQL/CreateMutationAuthorizationTests.cs b/src/Service.Tests/Authorization/GraphQL/CreateMutationAuthorizationTests.cs new file mode 100644 index 0000000000..fef0473c10 --- /dev/null +++ b/src/Service.Tests/Authorization/GraphQL/CreateMutationAuthorizationTests.cs @@ -0,0 +1,456 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Threading.Tasks; +using Azure.DataApiBuilder.Service.Exceptions; +using Azure.DataApiBuilder.Service.Tests.SqlTests; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Azure.DataApiBuilder.Service.Tests.Authorization.GraphQL +{ + [TestClass, TestCategory(TestCategory.MSSQL)] + public class CreateMutationAuthorizationTests : SqlTestBase + { + /// + /// Set the database engine for the tests + /// + [ClassInitialize] + public static async Task SetupAsync(TestContext context) + { + DatabaseEngine = TestCategory.MSSQL; + await InitializeTestFixture(); + } + + #region Point create mutation tests + + /// + /// Test to validate that a 'create one' point mutation request will fail if the user does not have create permission on the + /// top-level (the only) entity involved in the mutation. + /// + [TestMethod] + public async Task ValidateAuthZCheckOnEntitiesForCreateOnePointMutations() + { + string createPublisherMutationName = "createPublisher"; + string createOnePublisherMutation = @"mutation{ + createPublisher(item: {name: ""Publisher #1""}) + { + id + name + } + }"; + + // The anonymous role does not have create permissions on the Publisher entity. + // Hence the request will fail during authorization check. + await ValidateRequestIsUnauthorized( + graphQLMutationName: createPublisherMutationName, + graphQLMutation: createOnePublisherMutation, + expectedExceptionMessage: "The current user is not authorized to access this resource.", + isAuthenticated: false, + clientRoleHeader: "anonymous" + ); + } + + /// + /// Test to validate that a 'create one' point mutation will fail the AuthZ checks if the user does not have create permission + /// on one more columns belonging to the entity in the mutation. + /// + [TestMethod] + public async Task ValidateAuthZCheckOnColumnsForCreateOnePointMutations() + { + string createOneStockMutationName = "createStock"; + string createOneStockWithPiecesAvailable = @"mutation { + createStock( + item: + { + categoryid: 1, + pieceid: 2, + categoryName: ""xyz"" + piecesAvailable: 0 + } + ) + { + categoryid + pieceid + } + }"; + + // The 'test_role_with_excluded_fields_on_create' role does not have create permissions on + // stocks.piecesAvailable field and hence the authorization check should fail. + await ValidateRequestIsUnauthorized( + graphQLMutationName: createOneStockMutationName, + graphQLMutation: createOneStockWithPiecesAvailable, + expectedExceptionMessage: "Unauthorized due to one or more fields in this mutation.", + expectedExceptionStatusCode: DataApiBuilderException.SubStatusCodes.AuthorizationCheckFailed.ToString(), + isAuthenticated: true, + clientRoleHeader: "test_role_with_excluded_fields_on_create"); + } + + #endregion + + #region Multiple create mutation tests + /// + /// Test to validate that a 'create one' mutation request can only execute successfully when the user, has create permission + /// for all the entities involved in the mutation. + /// + [TestMethod] + public async Task ValidateAuthZCheckOnEntitiesForCreateOneMultipleMutations() + { + string createBookMutationName = "createbook"; + string createOneBookMutation = @"mutation { + createbook(item: { title: ""Book #1"", publishers: { name: ""Publisher #1""}}) { + id + title + } + }"; + + // The anonymous role has create permissions on the Book entity but not on the Publisher entity. + // Hence the request will fail during authorization check. + await ValidateRequestIsUnauthorized( + graphQLMutationName: createBookMutationName, + graphQLMutation: createOneBookMutation, + expectedExceptionMessage: "Unauthorized due to one or more fields in this mutation.", + expectedExceptionStatusCode: DataApiBuilderException.SubStatusCodes.AuthorizationCheckFailed.ToString(), + isAuthenticated: false, + clientRoleHeader: "anonymous" + ); + + // The authenticated role has create permissions on both the Book and Publisher entities. + // Hence the authorization checks will pass. + await ValidateRequestIsAuthorized( + graphQLMutationName: createBookMutationName, + graphQLMutation: createOneBookMutation, + isAuthenticated: true, + clientRoleHeader: "authenticated" + ); + } + + /// + /// Test to validate that a 'create multiple' mutation request can only execute successfully when the user, has create permission + /// for all the entities involved in the mutation. + /// + [TestMethod] + public async Task ValidateAuthZCheckOnEntitiesForCreateMultipleMutations() + { + string createMultipleBooksMutationName = "createbooks"; + string createMultipleBookMutation = @"mutation { + createbooks(items: [{ title: ""Book #1"", publisher_id: 1234 }, + { title: ""Book #2"", publishers: { name: ""Publisher #2""}}]) { + items{ + id + title + } + } + }"; + + // The anonymous role has create permissions on the Book entity but not on the Publisher entity. + // Hence the request will fail during authorization check. + await ValidateRequestIsUnauthorized( + graphQLMutationName: createMultipleBooksMutationName, + graphQLMutation: createMultipleBookMutation, + expectedExceptionMessage: "Unauthorized due to one or more fields in this mutation.", + expectedExceptionStatusCode: DataApiBuilderException.SubStatusCodes.AuthorizationCheckFailed.ToString(), + isAuthenticated: false, + clientRoleHeader: "anonymous"); + + // The authenticated role has create permissions on both the Book and Publisher entities. + // Hence the authorization checks will pass. + await ValidateRequestIsAuthorized( + graphQLMutationName: createMultipleBooksMutationName, + graphQLMutation: createMultipleBookMutation, + isAuthenticated: true, + clientRoleHeader: "authenticated", + expectedResult: "Expected item argument in mutation arguments." + ); + } + + /// + /// Test to validate that a 'create one' mutation request can only execute successfully when the user, in addition to having + /// create permission for all the entities involved in the create mutation, has the create permission for all the columns + /// present for each entity in the mutation. + /// If the user does not have any create permission on one or more column belonging to any of the entity in the + /// multiple-create mutation, the request will fail during authorization check. + /// + [TestMethod] + public async Task ValidateAuthZCheckOnColumnsForCreateOneMultipleMutations() + { + string createOneStockMutationName = "createStock"; + string createOneStockWithPiecesAvailable = @"mutation { + createStock( + item: + { + categoryid: 1, + pieceid: 2, + categoryName: ""xyz"" + piecesAvailable: 0, + stocks_price: + { + is_wholesale_price: true + instant: ""1996-01-24"" + } + } + ) + { + categoryid + pieceid + } + }"; + + // The 'test_role_with_excluded_fields_on_create' role does not have create permissions on + // stocks.piecesAvailable field and hence the authorization check should fail. + await ValidateRequestIsUnauthorized( + graphQLMutationName: createOneStockMutationName, + graphQLMutation: createOneStockWithPiecesAvailable, + expectedExceptionMessage: "Unauthorized due to one or more fields in this mutation.", + expectedExceptionStatusCode: DataApiBuilderException.SubStatusCodes.AuthorizationCheckFailed.ToString(), + isAuthenticated: true, + clientRoleHeader: "test_role_with_excluded_fields_on_create"); + + // As soon as we remove the 'piecesAvailable' column from the request body, + // the authorization check will pass. + string createOneStockWithoutPiecesAvailable = @"mutation { + createStock( + item: + { + categoryid: 1, + pieceid: 2, + categoryName: ""xyz"", + stocks_price: + { + is_wholesale_price: true + instant: ""1996-01-24"" + } + } + ) + { + categoryid + pieceid + } + }"; + + // Since the field stocks.piecesAvailable is not included in the mutation, + // the authorization check should pass. + await ValidateRequestIsAuthorized( + graphQLMutationName: createOneStockMutationName, + graphQLMutation: createOneStockWithoutPiecesAvailable, + isAuthenticated: true, + clientRoleHeader: "test_role_with_excluded_fields_on_create", + expectedResult: ""); + + // Executing a similar mutation request but with stocks_price as top-level entity. + // This validates that the recursive logic to do authorization on fields belonging to related entities + // work as expected. + + string createOneStockPriceMutationName = "createstocks_price"; + string createOneStocksPriceWithPiecesAvailable = @"mutation { + createstocks_price( + item: + { + is_wholesale_price: true, + instant: ""1996-01-24"", + price: 49.6, + Stock: + { + categoryid: 1, + pieceid: 2, + categoryName: ""xyz"" + piecesAvailable: 0, + } + } + ) + { + categoryid + pieceid + } + }"; + + // The 'test_role_with_excluded_fields_on_create' role does not have create permissions on + // stocks.piecesAvailable field and hence the authorization check should fail. + await ValidateRequestIsUnauthorized( + graphQLMutationName: createOneStockPriceMutationName, + graphQLMutation: createOneStocksPriceWithPiecesAvailable, + expectedExceptionMessage: "Unauthorized due to one or more fields in this mutation.", + expectedExceptionStatusCode: DataApiBuilderException.SubStatusCodes.AuthorizationCheckFailed.ToString(), + isAuthenticated: true, + clientRoleHeader: "test_role_with_excluded_fields_on_create"); + + string createOneStocksPriceWithoutPiecesAvailable = @"mutation { + createstocks_price( + item: + { + is_wholesale_price: true, + instant: ""1996-01-24"", + price: 49.6, + Stock: + { + categoryid: 1, + pieceid: 2, + categoryName: ""xyz"" + } + } + ) + { + categoryid + pieceid + } + }"; + + // Since the field stocks.piecesAvailable is not included in the mutation, + // the authorization check should pass. + await ValidateRequestIsAuthorized( + graphQLMutationName: createOneStockMutationName, + graphQLMutation: createOneStocksPriceWithoutPiecesAvailable, + isAuthenticated: true, + clientRoleHeader: "test_role_with_excluded_fields_on_create", + expectedResult: ""); + } + + /// + /// Test to validate that a 'create multiple' mutation request can only execute successfully when the user, in addition to having + /// create permission for all the entities involved in the create mutation, has the create permission for all the columns + /// present for each entity in the mutation. + /// If the user does not have any create permission on one or more column belonging to any of the entity in the + /// multiple-create mutation, the request will fail during authorization check. + /// + [TestMethod] + public async Task ValidateAuthZCheckOnColumnsForCreateMultipleMutations() + { + string createMultipleStockMutationName = "createStocks"; + string createMultipleStocksWithPiecesAvailable = @"mutation { + createStocks( + items: [ + { + categoryid: 1, + pieceid: 2, + categoryName: ""xyz"" + piecesAvailable: 0, + stocks_price: + { + is_wholesale_price: true + instant: ""1996-01-24"" + } + } + ]) + { + items + { + categoryid + pieceid + } + } + }"; + + // The 'test_role_with_excluded_fields_on_create' role does not have create permissions on + // stocks.piecesAvailable field and hence the authorization check should fail. + await ValidateRequestIsUnauthorized( + graphQLMutationName: createMultipleStockMutationName, + graphQLMutation: createMultipleStocksWithPiecesAvailable, + expectedExceptionMessage: "Unauthorized due to one or more fields in this mutation.", + expectedExceptionStatusCode: DataApiBuilderException.SubStatusCodes.AuthorizationCheckFailed.ToString(), + isAuthenticated: true, + clientRoleHeader: "test_role_with_excluded_fields_on_create"); + + // As soon as we remove the 'piecesAvailable' column from the request body, + // the authorization check will pass. + string createMultipleStocksWithoutPiecesAvailable = @"mutation { + createStocks( + items: [ + { + categoryid: 1, + pieceid: 2, + categoryName: ""xyz"" + piecesAvailable: 0, + stocks_price: + { + is_wholesale_price: true + instant: ""1996-01-24"" + } + } + ]) + { + items + { + categoryid + pieceid + } + } + }"; + + // Since the field stocks.piecesAvailable is not included in the mutation, + // the authorization check should pass. + await ValidateRequestIsAuthorized( + graphQLMutationName: createMultipleStockMutationName, + graphQLMutation: createMultipleStocksWithoutPiecesAvailable, + isAuthenticated: true, + clientRoleHeader: "test_role_with_excluded_fields_on_create", + expectedResult: ""); + } + + #endregion + + #region Test helpers + /// + /// Helper method to execute and validate response for negative GraphQL requests which expect an authorization failure + /// as a result of their execution. + /// + /// Name of the mutation. + /// Request body of the mutation. + /// Expected exception message. + /// Boolean indicating whether the request should be treated as authenticated or not. + /// Value of X-MS-API-ROLE client role header. + private async Task ValidateRequestIsUnauthorized( + string graphQLMutationName, + string graphQLMutation, + string expectedExceptionMessage, + string expectedExceptionStatusCode = null, + bool isAuthenticated = false, + string clientRoleHeader = "anonymous") + { + + JsonElement actual = await ExecuteGraphQLRequestAsync( + query: graphQLMutation, + queryName: graphQLMutationName, + isAuthenticated: isAuthenticated, + variables: null, + clientRoleHeader: clientRoleHeader); + + SqlTestHelper.TestForErrorInGraphQLResponse( + actual.ToString(), + message: expectedExceptionMessage, + statusCode: expectedExceptionStatusCode + ); + } + + /// + /// Helper method to execute and validate response for positive GraphQL requests which expect a successful execution + /// against the database, passing all the Authorization checks en route. + /// + /// Name of the mutation. + /// Request body of the mutation. + /// Expected result. + /// Boolean indicating whether the request should be treated as authenticated or not. + /// Value of X-MS-API-ROLE client role header. + private async Task ValidateRequestIsAuthorized( + string graphQLMutationName, + string graphQLMutation, + string expectedResult = "Value cannot be null", + bool isAuthenticated = false, + string clientRoleHeader = "anonymous") + { + + JsonElement actual = await ExecuteGraphQLRequestAsync( + query: graphQLMutation, + queryName: graphQLMutationName, + isAuthenticated: isAuthenticated, + variables: null, + clientRoleHeader: clientRoleHeader); + + SqlTestHelper.TestForErrorInGraphQLResponse( + actual.ToString(), + message: expectedResult + ); + } + + #endregion + } +} diff --git a/src/Service.Tests/Authorization/GraphQL/GraphQLMutationAuthorizationTests.cs b/src/Service.Tests/Authorization/GraphQL/GraphQLMutationAuthorizationTests.cs index a9355796ac..740684a579 100644 --- a/src/Service.Tests/Authorization/GraphQL/GraphQLMutationAuthorizationTests.cs +++ b/src/Service.Tests/Authorization/GraphQL/GraphQLMutationAuthorizationTests.cs @@ -44,13 +44,7 @@ public class GraphQLMutationAuthorizationTests /// If authorization fails, an exception is thrown and this test validates that scenario. /// If authorization succeeds, no exceptions are thrown for authorization, and function resolves silently. /// - /// - /// - /// - /// [DataTestMethod] - [DataRow(true, new string[] { "col1", "col2", "col3" }, new string[] { "col1" }, EntityActionOperation.Create, DisplayName = "Create Mutation Field Authorization - Success, Columns Allowed")] - [DataRow(false, new string[] { "col1", "col2", "col3" }, new string[] { "col1" }, EntityActionOperation.Create, DisplayName = "Create Mutation Field Authorization - Failure, Columns Forbidden")] [DataRow(true, new string[] { "col1", "col2", "col3" }, new string[] { "col1" }, EntityActionOperation.UpdateGraphQL, DisplayName = "Update Mutation Field Authorization - Success, Columns Allowed")] [DataRow(false, new string[] { "col1", "col2", "col3" }, new string[] { "col4" }, EntityActionOperation.UpdateGraphQL, DisplayName = "Update Mutation Field Authorization - Failure, Columns Forbidden")] [DataRow(true, new string[] { "col1", "col2", "col3" }, new string[] { "col1" }, EntityActionOperation.Delete, DisplayName = "Delete Mutation Field Authorization - Success, since authorization to perform the" + @@ -83,7 +77,7 @@ public void MutationFields_AuthorizationEvaluation(bool isAuthorized, string[] c bool authorizationResult = false; try { - engine.AuthorizeMutationFields( + engine.AuthorizeMutation( graphQLMiddlewareContext.Object, parameters, entityName: TEST_ENTITY, @@ -105,7 +99,6 @@ public void MutationFields_AuthorizationEvaluation(bool isAuthorized, string[] c /// Sets up test fixture for class, only to be run once per test run, as defined by /// MSTest decorator. /// - /// private static SqlMutationEngine SetupTestFixture(bool isAuthorized) { Mock _queryEngine = new(); diff --git a/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForMsSql.verified.txt b/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForMsSql.verified.txt index 349c8f4343..387450789c 100644 --- a/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForMsSql.verified.txt +++ b/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForMsSql.verified.txt @@ -301,6 +301,28 @@ } ] }, + { + Role: test_role_with_excluded_fields_on_create, + Actions: [ + { + Action: Create, + Fields: { + Exclude: [ + piecesAvailable + ] + } + }, + { + Action: Read + }, + { + Action: Update + }, + { + Action: Delete + } + ] + }, { Role: TestNestedFilterFieldIsNull_ColumnForbidden, Actions: [ @@ -1303,8 +1325,30 @@ Action: Create } ] + }, + { + Role: test_role_with_excluded_fields_on_create, + Actions: [ + { + Action: Create + }, + { + Action: Read + }, + { + Action: Update + }, + { + Action: Delete + } + ] } - ] + ], + Relationships: { + Stock: { + TargetEntity: Stock + } + } } }, { diff --git a/src/Service.Tests/dab-config.MsSql.json b/src/Service.Tests/dab-config.MsSql.json index be8a96d2c3..1743052c28 100644 --- a/src/Service.Tests/dab-config.MsSql.json +++ b/src/Service.Tests/dab-config.MsSql.json @@ -324,6 +324,28 @@ } ] }, + { + "role": "test_role_with_excluded_fields_on_create", + "actions": [ + { + "action": "create", + "fields": { + "exclude": [ + "piecesAvailable" + ] + } + }, + { + "action": "read" + }, + { + "action": "update" + }, + { + "action": "delete" + } + ] + }, { "role": "TestNestedFilterFieldIsNull_ColumnForbidden", "actions": [ @@ -396,6 +418,28 @@ } ] }, + { + "role": "test_role_with_excluded_fields_on_create", + "actions": [ + { + "action": "create", + "fields": { + "exclude": [ + "piecesAvailable" + ] + } + }, + { + "action": "read" + }, + { + "action": "update" + }, + { + "action": "delete" + } + ] + }, { "role": "test_role_with_policy_excluded_fields", "actions": [ @@ -1385,8 +1429,52 @@ "action": "create" } ] + }, + { + "role": "test_role_with_excluded_fields_on_create", + "actions": [ + { + "action": "create" + }, + { + "action": "read" + }, + { + "action": "update" + }, + { + "action": "delete" + } + ] + }, + { + "role": "test_role_with_excluded_fields_on_create", + "actions": [ + { + "action": "create" + }, + { + "action": "read" + }, + { + "action": "update" + }, + { + "action": "delete" + } + ] } - ] + ], + "relationships": { + "Stock": { + "cardinality": "one", + "target.entity": "Stock", + "source.fields": [], + "target.fields": [], + "linking.source.fields": [], + "linking.target.fields": [] + } + } }, "Tree": { "source": { From 9a90ca1599665a46a6297f0487b1f3c46cb87af3 Mon Sep 17 00:00:00 2001 From: Shyam Sundar J Date: Tue, 26 Mar 2024 14:35:27 +0530 Subject: [PATCH 7/8] Enable or Disable Multiple Create operation based on feature flag value (#2116) ## Why make this change? - Closes https://github.com/Azure/data-api-builder/issues/1951 - PR https://github.com/Azure/data-api-builder/pull/1983, https://github.com/Azure/data-api-builder/pull/2103 add CLI options to enable or disable multiple mutation/multiple create operation through CLI. With the changes introduced in the mentioned PRs, the configuration properties successfully gets written to the config file. Also, during deserialization, the properties are read and the `MultipleMutationOptions`, `MultipleCreateOptions`, `GraphQLRuntimeOptions` objects are created accordingly. - The above-mentioned PRs do not introduce any change in DAB engine behavior depending on the configuration property values. - This PR introduces changes to read these fields and enable/disable multiple create operation depending on whether the feature is enabled/disabled through the config file. This is achieved by introducing behavior changes in the schema generation. ## What is this change? - This PR builds on top of a) Schema generation PR https://github.com/Azure/data-api-builder/pull/1902 b) Rename nested-create to multiple create PR https://github.com/Azure/data-api-builder/pull/2103 - When multiple create operation is disabled, > i) Fields belonging to the related entities are not created in the input object type are not created. > ii) Many type multiple create mutation nodes (ex: `createbooks`, `createpeople_multiple` ) are not created. > iii) ReferencingField directive is not applied on relationship fields, so they continue to remain required fields for the create mutation operation. > iv) Entities for linking objects are not created as they are relevant only in the context of multiple create operations. ## How was this tested? - [x] Unit Tests and Integration Tests - [x] Manual Tests **Note:** At the moment, multiple create operation is disabled in the config file generated for integration tests. This is because of the plan to merge in the Schema generation, AuthZ/N branches separately to the main branch. With just these 2 PRs, a multiple create operation will fail, hence, the disabling multiple create operation. At the moment, tests that perform validations specific to multiple create feature enable it by i) updating the runtime object (or) ii) creating a custom config in which the operation is enabled. ## Sample Request(s) ### When Multiple Create operation is enabled - MsSQL #### Related entity fields are created in the input object type ![image](https://github.com/Azure/data-api-builder/assets/11196553/7a3a8bbe-2742-43e0-98d7-9412ed05db33) #### Multiple type create operation is created in addition to point create operation ![image](https://github.com/Azure/data-api-builder/assets/11196553/c6513d9a-5b49-44cc-8fcc-1ed1f44f5f58) #### Querying related entities continue to work successfully ![image](https://github.com/Azure/data-api-builder/assets/11196553/4c1a61b8-0cbb-4a1e-afaa-1849d710be27) ### When Multiple Create operation is disabled - MsSQL #### Only fields belonging to the given entity are created in the input object type ![image](https://github.com/Azure/data-api-builder/assets/11196553/a3b6beb2-7245-4345-ba13-29d8905d859e) #### Multiple type create operation is not created ### When Multiple Create operation is enabled - Other relational database types #### Only fields belonging to the given entity are created in the input object type ![image](https://github.com/Azure/data-api-builder/assets/11196553/b2a4f7f6-b121-410d-806d-8c5772253080) #### Multiple type create operation is not created --------- Co-authored-by: Ayush Agarwal --- .../MultipleCreateSupportingDatabaseType.cs | 10 + src/Config/ObjectModel/RuntimeConfig.cs | 18 ++ src/Core/Services/GraphQLSchemaCreator.cs | 25 +- .../MsSqlMetadataProvider.cs | 5 +- .../MetadataProviders/SqlMetadataProvider.cs | 24 +- src/Service.GraphQLBuilder/GraphQLUtils.cs | 10 - .../Mutations/CreateMutationBuilder.cs | 89 ++++--- .../Mutations/MutationBuilder.cs | 21 +- .../CreateMutationAuthorizationTests.cs | 4 + .../Configuration/ConfigurationTests.cs | 247 ++++++++++++++++++ .../MultipleMutationBuilderTests.cs | 26 +- .../GraphQLBuilder/MutationBuilderTests.cs | 142 ++++++++-- 12 files changed, 532 insertions(+), 89 deletions(-) create mode 100644 src/Config/ObjectModel/MultipleCreateSupportingDatabaseType.cs diff --git a/src/Config/ObjectModel/MultipleCreateSupportingDatabaseType.cs b/src/Config/ObjectModel/MultipleCreateSupportingDatabaseType.cs new file mode 100644 index 0000000000..039354c991 --- /dev/null +++ b/src/Config/ObjectModel/MultipleCreateSupportingDatabaseType.cs @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.DataApiBuilder.Config.ObjectModel +{ + public enum MultipleCreateSupportingDatabaseType + { + MSSQL + } +} diff --git a/src/Config/ObjectModel/RuntimeConfig.cs b/src/Config/ObjectModel/RuntimeConfig.cs index e5e791228b..b115517b1a 100644 --- a/src/Config/ObjectModel/RuntimeConfig.cs +++ b/src/Config/ObjectModel/RuntimeConfig.cs @@ -453,4 +453,22 @@ public static bool IsHotReloadable() // always return false while hot reload is not an available feature. return false; } + + /// + /// Helper method to check if multiple create option is supported and enabled. + /// + /// Returns true when + /// 1. Multiple create operation is supported by the database type and + /// 2. Multiple create operation is enabled in the runtime config. + /// + /// + public bool IsMultipleCreateOperationEnabled() + { + return Enum.GetNames(typeof(MultipleCreateSupportingDatabaseType)).Any(x => x.Equals(DataSource.DatabaseType.ToString(), StringComparison.OrdinalIgnoreCase)) && + (Runtime is not null && + Runtime.GraphQL is not null && + Runtime.GraphQL.MultipleMutationOptions is not null && + Runtime.GraphQL.MultipleMutationOptions.MultipleCreateOptions is not null && + Runtime.GraphQL.MultipleMutationOptions.MultipleCreateOptions.Enabled); + } } diff --git a/src/Core/Services/GraphQLSchemaCreator.cs b/src/Core/Services/GraphQLSchemaCreator.cs index dfe1a1b0a6..76ba3218c8 100644 --- a/src/Core/Services/GraphQLSchemaCreator.cs +++ b/src/Core/Services/GraphQLSchemaCreator.cs @@ -42,6 +42,7 @@ public class GraphQLSchemaCreator private readonly RuntimeEntities _entities; private readonly IAuthorizationResolver _authorizationResolver; private readonly RuntimeConfigProvider _runtimeConfigProvider; + private bool _isMultipleCreateOperationEnabled; /// /// Initializes a new instance of the class. @@ -60,6 +61,7 @@ public GraphQLSchemaCreator( { RuntimeConfig runtimeConfig = runtimeConfigProvider.GetConfig(); + _isMultipleCreateOperationEnabled = runtimeConfig.IsMultipleCreateOperationEnabled(); _entities = runtimeConfig.Entities; _queryEngineFactory = queryEngineFactory; _mutationEngineFactory = mutationEngineFactory; @@ -137,7 +139,7 @@ private ISchemaBuilder Parse( DocumentNode queryNode = QueryBuilder.Build(root, entityToDatabaseType, _entities, inputTypes, _authorizationResolver.EntityPermissionsMap, entityToDbObjects); // Generate the GraphQL mutations from the provided objects - DocumentNode mutationNode = MutationBuilder.Build(root, entityToDatabaseType, _entities, _authorizationResolver.EntityPermissionsMap, entityToDbObjects); + DocumentNode mutationNode = MutationBuilder.Build(root, entityToDatabaseType, _entities, _authorizationResolver.EntityPermissionsMap, entityToDbObjects, _isMultipleCreateOperationEnabled); return (queryNode, mutationNode); } @@ -215,8 +217,7 @@ private DocumentNode GenerateSqlGraphQLObjects(RuntimeEntities entities, Diction configEntity: entity, entities: entities, rolesAllowedForEntity: rolesAllowedForEntity, - rolesAllowedForFields: rolesAllowedForFields - ); + rolesAllowedForFields: rolesAllowedForFields); if (databaseObject.SourceType is not EntitySourceType.StoredProcedure) { @@ -234,8 +235,13 @@ private DocumentNode GenerateSqlGraphQLObjects(RuntimeEntities entities, Diction } } - // For all the fields in the object which hold a foreign key reference to any referenced entity, add a foreign key directive. - AddReferencingFieldDirective(entities, objectTypes); + // 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) @@ -245,8 +251,13 @@ private DocumentNode GenerateSqlGraphQLObjects(RuntimeEntities entities, Diction // Create ObjectTypeDefinitionNode for linking entities. These object definitions are not exposed in the schema // but are used to generate the object definitions of directional linking entities for (source, target) and (target, source) entities. - Dictionary linkingObjectTypes = GenerateObjectDefinitionsForLinkingEntities(); - GenerateSourceTargetLinkingObjectDefinitions(objectTypes, linkingObjectTypes); + // 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); + } // Return a list of all the object types to be exposed in the schema. Dictionary fields = new(); diff --git a/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs b/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs index 60e8543512..8cd81462fb 100644 --- a/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs +++ b/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs @@ -29,6 +29,8 @@ namespace Azure.DataApiBuilder.Core.Services public class MsSqlMetadataProvider : SqlMetadataProvider { + private RuntimeConfigProvider _runtimeConfigProvider; + public MsSqlMetadataProvider( RuntimeConfigProvider runtimeConfigProvider, IAbstractQueryManagerFactory queryManagerFactory, @@ -37,6 +39,7 @@ public MsSqlMetadataProvider( bool isValidateOnly = false) : base(runtimeConfigProvider, queryManagerFactory, logger, dataSourceName, isValidateOnly) { + _runtimeConfigProvider = runtimeConfigProvider; } public override string GetDefaultSchemaName() @@ -219,7 +222,7 @@ protected override void PopulateMetadataForLinkingObject( string linkingObject, Dictionary sourceObjects) { - if (!GraphQLUtils.DoesRelationalDBSupportMultipleCreate(GetDatabaseType())) + if (!_runtimeConfigProvider.GetConfig().IsMultipleCreateOperationEnabled()) { // Currently we have this same class instantiated for both MsSql and DwSql. // This is a refactor we need to take care of in future. diff --git a/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs b/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs index 144f7c0df9..bf36f62e65 100644 --- a/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs +++ b/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs @@ -755,16 +755,22 @@ private void ProcessRelationships( referencedColumns: relationship.TargetFields, relationshipData); - // When a linking object is encountered for a database table, we will create a linking entity for the object. - // Subsequently, we will also populate the Database object for the linking entity. This is used to infer - // metadata about linking object needed to create GQL schema for multiple insertions. - if (entity.Source.Type is EntitySourceType.Table) + RuntimeConfig runtimeConfig = _runtimeConfigProvider.GetConfig(); + + // Populating metadata for linking object is only required when multiple create operation is enabled and those database types that support multiple create operation. + if (runtimeConfig.IsMultipleCreateOperationEnabled()) { - PopulateMetadataForLinkingObject( - entityName: entityName, - targetEntityName: targetEntityName, - linkingObject: relationship.LinkingObject, - sourceObjects: sourceObjects); + // When a linking object is encountered for a database table, we will create a linking entity for the object. + // Subsequently, we will also populate the Database object for the linking entity. This is used to infer + // metadata about linking object needed to create GQL schema for multiple insertions. + if (entity.Source.Type is EntitySourceType.Table) + { + PopulateMetadataForLinkingObject( + entityName: entityName, + targetEntityName: targetEntityName, + linkingObject: relationship.LinkingObject, + sourceObjects: sourceObjects); + } } } else if (relationship.Cardinality == Cardinality.One) diff --git a/src/Service.GraphQLBuilder/GraphQLUtils.cs b/src/Service.GraphQLBuilder/GraphQLUtils.cs index bb786d0be7..3b3614e74a 100644 --- a/src/Service.GraphQLBuilder/GraphQLUtils.cs +++ b/src/Service.GraphQLBuilder/GraphQLUtils.cs @@ -32,8 +32,6 @@ public static class GraphQLUtils // Delimiter used to separate linking entity prefix/source entity name/target entity name, in the name of a linking entity. private const string ENTITY_NAME_DELIMITER = "$"; - public static HashSet RELATIONAL_DBS_SUPPORTING_MULTIPLE_CREATE = new() { DatabaseType.MSSQL }; - public static HashSet RELATIONAL_DBS = new() { DatabaseType.MSSQL, DatabaseType.MySQL, DatabaseType.DWSQL, DatabaseType.PostgreSQL, DatabaseType.CosmosDB_PostgreSQL }; @@ -72,14 +70,6 @@ public static bool IsBuiltInType(ITypeNode typeNode) return builtInTypes.Contains(name); } - /// - /// Helper method to evaluate whether DAB supports multiple create for a particular database type. - /// - public static bool DoesRelationalDBSupportMultipleCreate(DatabaseType databaseType) - { - return RELATIONAL_DBS_SUPPORTING_MULTIPLE_CREATE.Contains(databaseType); - } - /// /// Helper method to evaluate whether database type represents a NoSQL database. /// diff --git a/src/Service.GraphQLBuilder/Mutations/CreateMutationBuilder.cs b/src/Service.GraphQLBuilder/Mutations/CreateMutationBuilder.cs index 62b35c7e2e..7581663b9e 100644 --- a/src/Service.GraphQLBuilder/Mutations/CreateMutationBuilder.cs +++ b/src/Service.GraphQLBuilder/Mutations/CreateMutationBuilder.cs @@ -30,6 +30,7 @@ public static class CreateMutationBuilder /// All named GraphQL items in the schema (objects, enums, scalars, etc.) /// Database type of the relational database to generate input type for. /// Runtime config information. + /// Indicates whether multiple create operation is enabled /// A GraphQL input type with all expected fields mapped as GraphQL inputs. private static InputObjectTypeDefinitionNode GenerateCreateInputTypeForRelationalDb( Dictionary inputs, @@ -39,7 +40,8 @@ private static InputObjectTypeDefinitionNode GenerateCreateInputTypeForRelationa NameNode baseEntityName, IEnumerable definitions, DatabaseType databaseType, - RuntimeEntities entities) + RuntimeEntities entities, + bool IsMultipleCreateOperationEnabled) { NameNode inputName = GenerateInputTypeName(name.Value); @@ -59,7 +61,7 @@ private static InputObjectTypeDefinitionNode GenerateCreateInputTypeForRelationa .Where(field => IsBuiltInType(field.Type) && !IsAutoGeneratedField(field)) .Select(field => { - return GenerateScalarInputType(name, field, databaseType); + return GenerateScalarInputType(name, field, IsMultipleCreateOperationEnabled); }); // Add scalar input fields to list of input fields for current input type. @@ -82,14 +84,16 @@ private static InputObjectTypeDefinitionNode GenerateCreateInputTypeForRelationa // we find that the input object has already been created for the entity. inputs.Add(input.Name, input); - // Generate fields for related entities only if multiple mutations are supported for the database flavor. - if (DoesRelationalDBSupportMultipleCreate(databaseType)) + // Generate fields for related entities when + // 1. Multiple mutation operations are supported for the database type. + // 2. Multiple mutation operations are enabled. + if (IsMultipleCreateOperationEnabled) { // 2. Complex input fields. // Evaluate input objects for related entities. IEnumerable complexInputFields = objectTypeDefinitionNode.Fields - .Where(field => !IsBuiltInType(field.Type) && IsComplexFieldAllowedForCreateInputInRelationalDb(field, databaseType, definitions)) + .Where(field => !IsBuiltInType(field.Type) && IsComplexFieldAllowedForCreateInputInRelationalDb(field, definitions)) .Select(field => { string typeName = RelationshipDirectiveType.Target(field); @@ -130,7 +134,8 @@ private static InputObjectTypeDefinitionNode GenerateCreateInputTypeForRelationa targetObjectTypeName: baseObjectTypeNameForField, objectTypeDefinitionNode: (ObjectTypeDefinitionNode)def, databaseType: databaseType, - entities: entities); + entities: entities, + IsMultipleCreateOperationEnabled: IsMultipleCreateOperationEnabled); } // Get entity definition for this ObjectTypeDefinitionNode. @@ -144,7 +149,8 @@ private static InputObjectTypeDefinitionNode GenerateCreateInputTypeForRelationa targetObjectTypeName: new(typeName), objectTypeDefinitionNode: (ObjectTypeDefinitionNode)def, databaseType: databaseType, - entities: entities); + entities: entities, + IsMultipleCreateOperationEnabled: IsMultipleCreateOperationEnabled); }); // Append relationship fields to the input fields. inputFields.AddRange(complexInputFields); @@ -159,6 +165,8 @@ private static InputObjectTypeDefinitionNode GenerateCreateInputTypeForRelationa /// Reference table of all known input types. /// GraphQL object to generate the input type for. /// Name of the GraphQL object type. + /// In case when we are creating input type for linking object, baseEntityName is equal to the targetEntityName, + /// else baseEntityName is equal to the name parameter. /// All named GraphQL items in the schema (objects, enums, scalars, etc.) /// Database type of the non-relational database to generate input type for. /// Runtime config information. @@ -183,7 +191,7 @@ private static InputObjectTypeDefinitionNode GenerateCreateInputTypeForNonRelati { if (IsBuiltInType(field.Type)) { - return GenerateScalarInputType(name, field, databaseType); + return GenerateScalarInputType(name, field); } string typeName = RelationshipDirectiveType.Target(field); @@ -222,25 +230,24 @@ private static InputObjectTypeDefinitionNode GenerateCreateInputTypeForNonRelati } /// - /// This method is used to determine if a relationship field is allowed to be sent from the client in a Create mutation - /// for a relational database. If the field is a pagination field (for *:N relationships) or if we infer an object + /// This method is used to determine if a relationship field is allowed to be sent from the client in a Create mutation. + /// If the field is a pagination field (for *:N relationships) or if we infer an object /// definition for the field (for *:1 relationships), the field is allowed in the create input. /// /// Field to check - /// The type of database to generate for /// The other named types in the schema /// true if the field is allowed, false if it is not. - private static bool IsComplexFieldAllowedForCreateInputInRelationalDb(FieldDefinitionNode field, DatabaseType databaseType, IEnumerable definitions) + private static bool IsComplexFieldAllowedForCreateInputInRelationalDb(FieldDefinitionNode field, IEnumerable definitions) { if (QueryBuilder.IsPaginationType(field.Type.NamedType())) { - return DoesRelationalDBSupportMultipleCreate(databaseType); + return true; } HotChocolate.Language.IHasName? definition = definitions.FirstOrDefault(d => d.Name.Value == field.Type.NamedType().Name.Value); if (definition != null && definition is ObjectTypeDefinitionNode objectType && IsModelType(objectType)) { - return DoesRelationalDBSupportMultipleCreate(databaseType); + return true; } return false; @@ -262,7 +269,8 @@ private static bool DoesFieldHaveReferencingFieldDirective(FieldDefinitionNode f /// Name of the field. /// Field definition. /// Database type - private static InputValueDefinitionNode GenerateScalarInputType(NameNode name, FieldDefinitionNode fieldDefinition, DatabaseType databaseType) + /// Indicates whether multiple create operation is enabled + private static InputValueDefinitionNode GenerateScalarInputType(NameNode name, FieldDefinitionNode fieldDefinition, bool isMultipleCreateOperationEnabled = false) { IValueNode? defaultValue = null; @@ -271,8 +279,13 @@ private static InputValueDefinitionNode GenerateScalarInputType(NameNode name, F defaultValue = value.Fields[0].Value; } - bool isFieldNullable = defaultValue is not null || - (IsRelationalDb(databaseType) && DoesRelationalDBSupportMultipleCreate(databaseType) && DoesFieldHaveReferencingFieldDirective(fieldDefinition)); + bool isFieldNullable = defaultValue is not null; + + if (isMultipleCreateOperationEnabled && + DoesFieldHaveReferencingFieldDirective(fieldDefinition)) + { + isFieldNullable = true; + } return new( location: null, @@ -307,7 +320,8 @@ private static InputValueDefinitionNode GenerateComplexInputTypeForRelationalDb( NameNode targetObjectTypeName, ObjectTypeDefinitionNode objectTypeDefinitionNode, DatabaseType databaseType, - RuntimeEntities entities) + RuntimeEntities entities, + bool IsMultipleCreateOperationEnabled) { InputObjectTypeDefinitionNode node; NameNode inputTypeName = GenerateInputTypeName(typeName); @@ -321,14 +335,15 @@ private static InputValueDefinitionNode GenerateComplexInputTypeForRelationalDb( targetObjectTypeName, definitions, databaseType, - entities); + entities, + IsMultipleCreateOperationEnabled); } else { node = inputs[inputTypeName]; } - return GetComplexInputType(field, databaseType, node, inputTypeName); + return GetComplexInputType(field, node, inputTypeName, IsMultipleCreateOperationEnabled); } /// @@ -365,7 +380,8 @@ private static InputValueDefinitionNode GenerateComplexInputTypeForNonRelational node = inputs[inputTypeName]; } - return GetComplexInputType(field, databaseType, node, inputTypeName); + // For non-relational databases, multiple create operation is not supported. Hence, IsMultipleCreateOperationEnabled parameter is set to false. + return GetComplexInputType(field, node, inputTypeName, IsMultipleCreateOperationEnabled: false); } /// @@ -376,15 +392,16 @@ private static InputValueDefinitionNode GenerateComplexInputTypeForNonRelational /// Database type. /// Related field's InputObjectTypeDefinitionNode. /// Input type name of the parent entity. + /// Indicates whether multiple create operation is supported by the database type and is enabled through config file /// private static InputValueDefinitionNode GetComplexInputType( FieldDefinitionNode relatedFieldDefinition, - DatabaseType databaseType, InputObjectTypeDefinitionNode relatedFieldInputObjectTypeDefinition, - NameNode parentInputTypeName) + NameNode parentInputTypeName, + bool IsMultipleCreateOperationEnabled) { ITypeNode type = new NamedTypeNode(relatedFieldInputObjectTypeDefinition.Name); - if (IsRelationalDb(databaseType) && DoesRelationalDBSupportMultipleCreate(databaseType)) + if (IsMultipleCreateOperationEnabled) { if (RelationshipDirectiveType.Cardinality(relatedFieldDefinition) is Cardinality.Many) { @@ -470,6 +487,7 @@ public static NameNode GenerateInputTypeName(string typeName) /// Entity name specified in the runtime config. /// Name of type to be returned by the mutation. /// Collection of role names allowed for action, to be added to authorize directive. + /// Indicates whether multiple create operation is enabled /// A GraphQL field definition named create*EntityName* to be attached to the Mutations type in the GraphQL schema. public static IEnumerable Build( NameNode name, @@ -480,7 +498,8 @@ public static IEnumerable Build( RuntimeEntities entities, string dbEntityName, string returnEntityName, - IEnumerable? rolesAllowedForMutation = null) + IEnumerable? rolesAllowedForMutation = null, + bool IsMultipleCreateOperationEnabled = false) { List createMutationNodes = new(); Entity entity = entities[dbEntityName]; @@ -504,7 +523,8 @@ public static IEnumerable Build( baseEntityName: name, definitions: root.Definitions.Where(d => d is HotChocolate.Language.IHasName).Cast(), databaseType: databaseType, - entities: entities); + entities: entities, + IsMultipleCreateOperationEnabled: IsMultipleCreateOperationEnabled); } List fieldDefinitionNodeDirectives = new() { new(ModelDirectiveType.DirectiveName, new ArgumentNode(ModelDirectiveType.ModelNameArgument, dbEntityName)) }; @@ -539,7 +559,8 @@ public static IEnumerable Build( createMutationNodes.Add(createOneNode); - if (IsRelationalDb(databaseType) && DoesRelationalDBSupportMultipleCreate(databaseType)) + // Multiple create node is created in the schema only when multiple create operation is enabled. + if (IsMultipleCreateOperationEnabled) { // Create multiple node. FieldDefinitionNode createMultipleNode = new( @@ -547,13 +568,13 @@ public static IEnumerable Build( name: new NameNode(GetMultipleCreateMutationNodeName(name.Value, entity)), description: new StringValueNode($"Creates multiple new {GetDefinedPluralName(name.Value, entity)}"), arguments: new List { - new( - location : null, - new NameNode(MutationBuilder.ARRAY_INPUT_ARGUMENT_NAME), - new StringValueNode($"Input representing all the fields for creating {name}"), - new ListTypeNode(new NonNullTypeNode(new NamedTypeNode(input.Name))), - defaultValue: null, - new List()) + new( + location : null, + new NameNode(MutationBuilder.ARRAY_INPUT_ARGUMENT_NAME), + new StringValueNode($"Input representing all the fields for creating {name}"), + new ListTypeNode(new NonNullTypeNode(new NamedTypeNode(input.Name))), + defaultValue: null, + new List()) }, type: new NamedTypeNode(QueryBuilder.GeneratePaginationTypeName(GetDefinedSingularName(dbEntityName, entity))), directives: fieldDefinitionNodeDirectives diff --git a/src/Service.GraphQLBuilder/Mutations/MutationBuilder.cs b/src/Service.GraphQLBuilder/Mutations/MutationBuilder.cs index 95e63210de..755a8a0d0e 100644 --- a/src/Service.GraphQLBuilder/Mutations/MutationBuilder.cs +++ b/src/Service.GraphQLBuilder/Mutations/MutationBuilder.cs @@ -31,13 +31,15 @@ public static class MutationBuilder /// Map of entityName -> EntityMetadata /// Permissions metadata defined in runtime config. /// Database object metadata + /// Indicates whether multiple create operation is enabled /// Mutations DocumentNode public static DocumentNode Build( DocumentNode root, Dictionary databaseTypes, RuntimeEntities entities, Dictionary? entityPermissionsMap = null, - Dictionary? dbObjects = null) + Dictionary? dbObjects = null, + bool IsMultipleCreateOperationEnabled = false) { List mutationFields = new(); Dictionary inputs = new(); @@ -74,7 +76,7 @@ public static DocumentNode Build( else { string returnEntityName = databaseTypes[dbEntityName] is DatabaseType.DWSQL ? GraphQLUtils.DB_OPERATION_RESULT_TYPE : name.Value; - AddMutations(dbEntityName, operation: EntityActionOperation.Create, entityPermissionsMap, name, inputs, objectTypeDefinitionNode, root, databaseTypes[dbEntityName], entities, mutationFields, returnEntityName); + AddMutations(dbEntityName, operation: EntityActionOperation.Create, entityPermissionsMap, name, inputs, objectTypeDefinitionNode, root, databaseTypes[dbEntityName], entities, mutationFields, returnEntityName, IsMultipleCreateOperationEnabled); AddMutations(dbEntityName, operation: EntityActionOperation.Update, entityPermissionsMap, name, inputs, objectTypeDefinitionNode, root, databaseTypes[dbEntityName], entities, mutationFields, returnEntityName); AddMutations(dbEntityName, operation: EntityActionOperation.Delete, entityPermissionsMap, name, inputs, objectTypeDefinitionNode, root, databaseTypes[dbEntityName], entities, mutationFields, returnEntityName); } @@ -108,6 +110,7 @@ public static DocumentNode Build( /// /// /// + /// Indicates whether multiple create operation is enabled /// private static void AddMutations( string dbEntityName, @@ -120,7 +123,8 @@ private static void AddMutations( DatabaseType databaseType, RuntimeEntities entities, List mutationFields, - string returnEntityName + string returnEntityName, + bool IsMultipleCreateOperationEnabled = false ) { IEnumerable rolesAllowedForMutation = IAuthorizationResolver.GetRolesForOperation(dbEntityName, operation: operation, entityPermissionsMap); @@ -130,7 +134,16 @@ string returnEntityName { case EntityActionOperation.Create: // Get the create one/many fields for the create mutation. - IEnumerable createMutationNodes = CreateMutationBuilder.Build(name, inputs, objectTypeDefinitionNode, root, databaseType, entities, dbEntityName, returnEntityName, rolesAllowedForMutation); + IEnumerable createMutationNodes = CreateMutationBuilder.Build(name, + inputs, + objectTypeDefinitionNode, + root, + databaseType, + entities, + dbEntityName, + returnEntityName, + rolesAllowedForMutation, + IsMultipleCreateOperationEnabled); mutationFields.AddRange(createMutationNodes); break; case EntityActionOperation.Update: diff --git a/src/Service.Tests/Authorization/GraphQL/CreateMutationAuthorizationTests.cs b/src/Service.Tests/Authorization/GraphQL/CreateMutationAuthorizationTests.cs index fef0473c10..1cc0e9f1b4 100644 --- a/src/Service.Tests/Authorization/GraphQL/CreateMutationAuthorizationTests.cs +++ b/src/Service.Tests/Authorization/GraphQL/CreateMutationAuthorizationTests.cs @@ -94,6 +94,7 @@ await ValidateRequestIsUnauthorized( /// for all the entities involved in the mutation. /// [TestMethod] + [Ignore] public async Task ValidateAuthZCheckOnEntitiesForCreateOneMultipleMutations() { string createBookMutationName = "createbook"; @@ -130,6 +131,7 @@ await ValidateRequestIsAuthorized( /// for all the entities involved in the mutation. /// [TestMethod] + [Ignore] public async Task ValidateAuthZCheckOnEntitiesForCreateMultipleMutations() { string createMultipleBooksMutationName = "createbooks"; @@ -172,6 +174,7 @@ await ValidateRequestIsAuthorized( /// multiple-create mutation, the request will fail during authorization check. /// [TestMethod] + [Ignore] public async Task ValidateAuthZCheckOnColumnsForCreateOneMultipleMutations() { string createOneStockMutationName = "createStock"; @@ -313,6 +316,7 @@ await ValidateRequestIsAuthorized( /// multiple-create mutation, the request will fail during authorization check. /// [TestMethod] + [Ignore] public async Task ValidateAuthZCheckOnColumnsForCreateMultipleMutations() { string createMultipleStockMutationName = "createStocks"; diff --git a/src/Service.Tests/Configuration/ConfigurationTests.cs b/src/Service.Tests/Configuration/ConfigurationTests.cs index e6e955a098..5237a471b5 100644 --- a/src/Service.Tests/Configuration/ConfigurationTests.cs +++ b/src/Service.Tests/Configuration/ConfigurationTests.cs @@ -2030,6 +2030,181 @@ public async Task ValidateErrorMessageForMutationWithoutReadPermission() } } + /// + /// Multiple mutation operations are disabled through the configuration properties. + /// + /// Test to validate that when multiple-create is disabled: + /// 1. Including a relationship field in the input for create mutation for an entity returns an exception as when multiple mutations are disabled, + /// we don't add fields for relationships in the input type schema and hence users should not be able to do insertion in the related entities. + /// + /// 2. Excluding all the relationship fields i.e. performing insertion in just the top-level entity executes successfully. + /// + /// 3. Relationship fields are marked as optional fields in the schema when multiple create operation is enabled. However, when multiple create operations + /// are disabled, the relationship fields should continue to be marked as required fields. + /// With multiple create operation disabled, executing a create mutation operation without a relationship field ("publisher_id" in createbook mutation operation) should be caught by + /// HotChocolate since it is a required field. + /// + [TestMethod] + [TestCategory(TestCategory.MSSQL)] + public async Task ValidateMultipleCreateAndCreateMutationWhenMultipleCreateOperationIsDisabled() + { + // Generate a custom config file with multiple create operation disabled. + RuntimeConfig runtimeConfig = InitialzieRuntimeConfigForMultipleCreateTests(isMultipleCreateOperationEnabled: false); + + const string CUSTOM_CONFIG = "custom-config.json"; + + File.WriteAllText(CUSTOM_CONFIG, runtimeConfig.ToJson()); + string[] args = new[] + { + $"--ConfigFileName={CUSTOM_CONFIG}" + }; + + using (TestServer server = new(Program.CreateWebHostBuilder(args))) + using (HttpClient client = server.CreateClient()) + { + // When multiple create operation is disabled, fields belonging to related entities are not generated for the input type objects of create operation. + // Executing a create mutation with fields belonging to related entities should be caught by Hotchocolate as unrecognized fields. + string pointMultipleCreateOperation = @"mutation createbook{ + createbook(item: { title: ""Book #1"", publishers: { name: ""The First Publisher"" } }) { + id + title + } + }"; + + JsonElement mutationResponse = await GraphQLRequestExecutor.PostGraphQLRequestAsync(client, + server.Services.GetRequiredService(), + query: pointMultipleCreateOperation, + queryName: "createbook", + variables: null, + clientRoleHeader: null); + + Assert.IsNotNull(mutationResponse); + + SqlTestHelper.TestForErrorInGraphQLResponse(mutationResponse.ToString(), + message: "The specified input object field `publishers` does not exist.", + path: @"[""createbook""]"); + + // When multiple create operation is enabled, two types of create mutation operations are generated 1) Point create mutation operation 2) Many type create mutation operation. + // When multiple create operation is disabled, only point create mutation operation is generated. + // With multiple create operation disabled, executing a many type multiple create operation should be caught by HotChocolate as the many type mutation operation should not exist in the schema. + string manyTypeMultipleCreateOperation = @"mutation { + createbooks( + items: [ + { title: ""Book #1"", publishers: { name: ""Publisher #1"" } } + { title: ""Book #2"", publisher_id: 1234 } + ] + ) { + items { + id + title + } + } + }"; + + mutationResponse = await GraphQLRequestExecutor.PostGraphQLRequestAsync(client, + server.Services.GetRequiredService(), + query: manyTypeMultipleCreateOperation, + queryName: "createbook", + variables: null, + clientRoleHeader: null); + + Assert.IsNotNull(mutationResponse); + SqlTestHelper.TestForErrorInGraphQLResponse(mutationResponse.ToString(), + message: "The field `createbooks` does not exist on the type `Mutation`."); + + // Sanity test to validate that executing a point create mutation with multiple create operation disabled, + // a) Creates the new item successfully. + // b) Returns the expected response. + string pointCreateOperation = @"mutation createbook{ + createbook(item: { title: ""Book #1"", publisher_id: 1234 }) { + title + publisher_id + } + }"; + + mutationResponse = await GraphQLRequestExecutor.PostGraphQLRequestAsync(client, + server.Services.GetRequiredService(), + query: pointCreateOperation, + queryName: "createbook", + variables: null, + clientRoleHeader: null); + + string expectedResponse = @"{ ""title"":""Book #1"",""publisher_id"":1234}"; + + Assert.IsNotNull(mutationResponse); + SqlTestHelper.PerformTestEqualJsonStrings(expectedResponse, mutationResponse.ToString()); + + // When a create multiple operation is enabled, the "publisher_id" field will be generated as an optional field in the schema. But, when multiple create operation is disabled, + // "publisher_id" should be a required field. + // With multiple create operation disabled, executing a createbook mutation operation without the "publisher_id" field is expected to be caught by HotChocolate + // as the schema should be generated with "publisher_id" as a required field. + string pointCreateOperationWithMissingFields = @"mutation createbook{ + createbook(item: { title: ""Book #1""}) { + title + publisher_id + } + }"; + + mutationResponse = await GraphQLRequestExecutor.PostGraphQLRequestAsync(client, + server.Services.GetRequiredService(), + query: pointCreateOperationWithMissingFields, + queryName: "createbook", + variables: null, + clientRoleHeader: null); + + Assert.IsNotNull(mutationResponse); + SqlTestHelper.TestForErrorInGraphQLResponse(response: mutationResponse.ToString(), + message: "`publisher_id` is a required field and cannot be null."); + } + } + + /// + /// When multiple create operation is enabled, the relationship fields are generated as optional fields in the schema. + /// However, when not providing the relationship field as well the related object in the create mutation request should result in an error from the database layer. + /// + [TestMethod] + [TestCategory(TestCategory.MSSQL)] + public async Task ValidateCreateMutationWithMissingFieldsFailWithMultipleCreateEnabled() + { + // Multiple create operations are enabled. + RuntimeConfig runtimeConfig = InitialzieRuntimeConfigForMultipleCreateTests(isMultipleCreateOperationEnabled: true); + + const string CUSTOM_CONFIG = "custom-config.json"; + + File.WriteAllText(CUSTOM_CONFIG, runtimeConfig.ToJson()); + string[] args = new[] + { + $"--ConfigFileName={CUSTOM_CONFIG}" + }; + + using (TestServer server = new(Program.CreateWebHostBuilder(args))) + using (HttpClient client = server.CreateClient()) + { + + // When a create multiple operation is enabled, the "publisher_id" field will generated as an optional field in the schema. But, when multiple create operation is disabled, + // "publisher_id" should be a required field. + // With multiple create operation disabled, executing a createbook mutation operation without the "publisher_id" field is expected to be caught by HotChocolate + // as the schema should be generated with "publisher_id" as a required field. + string pointCreateOperationWithMissingFields = @"mutation createbook{ + createbook(item: { title: ""Book #1""}) { + title + publisher_id + } + }"; + + JsonElement mutationResponse = await GraphQLRequestExecutor.PostGraphQLRequestAsync(client, + server.Services.GetRequiredService(), + query: pointCreateOperationWithMissingFields, + queryName: "createbook", + variables: null, + clientRoleHeader: null); + + Assert.IsNotNull(mutationResponse); + SqlTestHelper.TestForErrorInGraphQLResponse(response: mutationResponse.ToString(), + message: "Cannot insert the value NULL into column 'publisher_id', table 'master.dbo.books'; column does not allow nulls. INSERT fails."); + } + } + /// /// For mutation operations, the respective mutation operation type(create/update/delete) + read permissions are needed to receive a valid response. /// For graphQL requests, if read permission is configured for Anonymous role, then it is inherited by other roles. @@ -3324,6 +3499,78 @@ private static async Task GetGraphQLResponsePostConfigHydration( return responseCode; } + /// + /// Helper method to instantiate RuntimeConfig object needed for multiple create tests. + /// + /// + public static RuntimeConfig InitialzieRuntimeConfigForMultipleCreateTests(bool isMultipleCreateOperationEnabled) + { + // Multiple create operations are enabled. + GraphQLRuntimeOptions graphqlOptions = new(Enabled: true, MultipleMutationOptions: new(new(enabled: isMultipleCreateOperationEnabled))); + + RestRuntimeOptions restRuntimeOptions = new(Enabled: false); + + DataSource dataSource = new(DatabaseType.MSSQL, GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), Options: null); + + EntityAction createAction = new( + Action: EntityActionOperation.Create, + Fields: null, + Policy: new()); + + EntityAction readAction = new( + Action: EntityActionOperation.Read, + Fields: null, + Policy: new()); + + EntityPermission[] permissions = new[] { new EntityPermission(Role: AuthorizationResolver.ROLE_ANONYMOUS, Actions: new[] { readAction, createAction }) }; + + EntityRelationship bookRelationship = new(Cardinality: Cardinality.One, + TargetEntity: "Publisher", + SourceFields: new string[] { }, + TargetFields: new string[] { }, + LinkingObject: null, + LinkingSourceFields: null, + LinkingTargetFields: null); + + Entity bookEntity = new(Source: new("books", EntitySourceType.Table, null, null), + Rest: null, + GraphQL: new(Singular: "book", Plural: "books"), + Permissions: permissions, + Relationships: new Dictionary() { { "publishers", bookRelationship } }, + Mappings: null); + + string bookEntityName = "Book"; + + Dictionary entityMap = new() + { + { bookEntityName, bookEntity } + }; + + EntityRelationship publisherRelationship = new(Cardinality: Cardinality.Many, + TargetEntity: "Book", + SourceFields: new string[] { }, + TargetFields: new string[] { }, + LinkingObject: null, + LinkingSourceFields: null, + LinkingTargetFields: null); + + Entity publisherEntity = new( + Source: new("publishers", EntitySourceType.Table, null, null), + Rest: null, + GraphQL: new(Singular: "publisher", Plural: "publishers"), + Permissions: permissions, + Relationships: new Dictionary() { { "books", publisherRelationship } }, + Mappings: null); + + entityMap.Add("Publisher", publisherEntity); + + RuntimeConfig runtimeConfig = new(Schema: "IntegrationTestMinimalSchema", + DataSource: dataSource, + Runtime: new(restRuntimeOptions, graphqlOptions, Host: new(Cors: null, Authentication: null, Mode: HostMode.Development), Cache: null), + Entities: new(entityMap)); + return runtimeConfig; + } + /// /// Instantiate minimal runtime config with custom global settings. /// diff --git a/src/Service.Tests/GraphQLBuilder/MultipleMutationBuilderTests.cs b/src/Service.Tests/GraphQLBuilder/MultipleMutationBuilderTests.cs index 714a80c4d2..db0d992113 100644 --- a/src/Service.Tests/GraphQLBuilder/MultipleMutationBuilderTests.cs +++ b/src/Service.Tests/GraphQLBuilder/MultipleMutationBuilderTests.cs @@ -349,9 +349,31 @@ public static async Task InitializeAsync() private static RuntimeConfigProvider GetRuntimeConfigProvider() { TestHelper.SetupDatabaseEnvironment(databaseEngine); - // Get the base config file from disk FileSystemRuntimeConfigLoader configPath = TestHelper.GetRuntimeConfigLoader(); - return new(configPath); + RuntimeConfigProvider provider = new(configPath); + + RuntimeConfig runtimeConfig = provider.GetConfig(); + + // Enabling multiple create operation because all the validations in this test file are specific + // to multiple create operation. + runtimeConfig = runtimeConfig with + { + Runtime = new RuntimeOptions(Rest: runtimeConfig.Runtime.Rest, + GraphQL: new GraphQLRuntimeOptions(MultipleMutationOptions: new MultipleMutationOptions(new MultipleCreateOptions(enabled: true))), + Host: runtimeConfig.Runtime.Host, + BaseRoute: runtimeConfig.Runtime.BaseRoute, + Telemetry: runtimeConfig.Runtime.Telemetry, + Cache: runtimeConfig.Runtime.Cache) + }; + + // For testing different aspects of schema generation for multiple create operation, we need to create a RuntimeConfigProvider object which contains a RuntimeConfig object + // with the multiple create operation enabled. + // So, another RuntimeConfigProvider object is created with the modified runtimeConfig and returned. + System.IO.Abstractions.TestingHelpers.MockFileSystem fileSystem = new(); + fileSystem.AddFile(FileSystemRuntimeConfigLoader.DEFAULT_CONFIG_FILE_NAME, runtimeConfig.ToJson()); + FileSystemRuntimeConfigLoader loader = new(fileSystem); + RuntimeConfigProvider runtimeConfigProvider = new(loader); + return runtimeConfigProvider; } /// diff --git a/src/Service.Tests/GraphQLBuilder/MutationBuilderTests.cs b/src/Service.Tests/GraphQLBuilder/MutationBuilderTests.cs index 2c7e3ae22c..57ac444ec7 100644 --- a/src/Service.Tests/GraphQLBuilder/MutationBuilderTests.cs +++ b/src/Service.Tests/GraphQLBuilder/MutationBuilderTests.cs @@ -1012,7 +1012,10 @@ public static ObjectTypeDefinitionNode GetMutationNode(DocumentNode mutationRoot /// When singular and plural names are specified by the user, these names will be used for generating the /// queries and mutations in the schema. /// When singular and plural names are not provided, the queries and mutations will be generated with the entity's name. - /// This test validates that this naming convention is followed for the mutations when the schema is generated. + /// + /// This test validates a) Number of mutation fields generated b) Mutation field names c) Mutation field descriptions + /// when multiple create operations are disabled. + /// /// /// Type definition for the entity /// Name of the entity @@ -1021,20 +1024,20 @@ public static ObjectTypeDefinitionNode GetMutationNode(DocumentNode mutationRoot /// Expected name of the entity in the mutation. Used to construct the exact expected mutation names. [DataTestMethod] [DataRow(GraphQLTestHelpers.PEOPLE_GQL, new string[] { "People" }, null, null, new string[] { "People" }, - DisplayName = "Mutation name and description validation for singular entity name with singular plural not defined")] + DisplayName = "Mutation name and description validation for singular entity name with singular plural not defined - Multiple Create Operation disabled")] [DataRow(GraphQLTestHelpers.PEOPLE_GQL, new string[] { "People" }, new string[] { "Person" }, new string[] { "People" }, new string[] { "Person" }, - DisplayName = "Mutation name and description validation for plural entity name with singular plural defined")] + DisplayName = "Mutation name and description validation for plural entity name with singular plural defined - Multiple Create Operation disabled")] [DataRow(GraphQLTestHelpers.PEOPLE_GQL, new string[] { "People" }, new string[] { "Person" }, new string[] { "" }, new string[] { "Person" }, - DisplayName = "Mutation name and description validation for plural entity name with singular defined")] + DisplayName = "Mutation name and description validation for plural entity name with singular defined - Multiple Create Operation disabled")] [DataRow(GraphQLTestHelpers.PERSON_GQL, new string[] { "Person" }, null, null, new string[] { "Person" }, - DisplayName = "Mutation name and description validation for singular entity name with singular plural not defined")] + DisplayName = "Mutation name and description validation for singular entity name with singular plural not defined - Multiple Create Operation disabled")] [DataRow(GraphQLTestHelpers.PERSON_GQL, new string[] { "Person" }, new string[] { "Person" }, new string[] { "People" }, new string[] { "Person" }, - DisplayName = "Mutation name and description validation for singular entity name with singular plural defined")] + DisplayName = "Mutation name and description validation for singular entity name with singular plural defined - Multiple Create Operation disabled")] [DataRow(GraphQLTestHelpers.PERSON_GQL + GraphQLTestHelpers.BOOK_GQL, new string[] { "Person", "Book" }, new string[] { "Person", "Book" }, new string[] { "People", "Books" }, new string[] { "Person", "Book" }, - DisplayName = "Mutation name and description validation for multiple entities with singular, plural")] + DisplayName = "Mutation name and description validation for multiple entities with singular, plural - Multiple Create Operation disabled")] [DataRow(GraphQLTestHelpers.PERSON_GQL + GraphQLTestHelpers.BOOK_GQL, new string[] { "Person", "Book" }, null, null, new string[] { "Person", "Book" }, - DisplayName = "Mutation name and description validation for multiple entities with singular plural not defined")] - public void ValidateMutationsAreCreatedWithRightName( + DisplayName = "Mutation name and description validation for multiple entities with singular plural not defined - Multiple Create Operation disabled")] + public void ValidateMutationsAreCreatedWithRightNameWithMultipleCreateOperationDisabled( string gql, string[] entityNames, string[] singularNames, @@ -1064,7 +1067,8 @@ string[] expectedNames root, entityNameToDatabaseType, new(entityNameToEntity), - entityPermissionsMap: entityPermissionsMap + entityPermissionsMap: entityPermissionsMap, + IsMultipleCreateOperationEnabled: false ); ObjectTypeDefinitionNode mutation = GetMutationNode(mutationRoot); @@ -1072,23 +1076,110 @@ string[] expectedNames // The permissions are setup for create, update and delete operations. // So create, update and delete mutations should get generated. - // A Check to validate that the count of mutations generated is 4 - - // 1. 2 Create mutations (point/many) when db supports nested created, else 1. + // A Check to validate that the count of mutations generated is 3 - + // 1. 1 Create mutation // 2. 1 Update mutation // 3. 1 Delete mutation - int totalExpectedMutations = 0; - foreach ((_, DatabaseType dbType) in entityNameToDatabaseType) + int totalExpectedMutations = 3 * entityNames.Length; + Assert.AreEqual(totalExpectedMutations, mutation.Fields.Count); + + for (int i = 0; i < entityNames.Length; i++) { - if (GraphQLUtils.DoesRelationalDBSupportMultipleCreate(dbType)) - { - totalExpectedMutations += 4; - } - else - { - totalExpectedMutations += 3; - } + // Name and Description validations for Create mutation + string expectedCreateMutationName = $"create{expectedNames[i]}"; + string expectedCreateMutationDescription = $"Creates a new {expectedNames[i]}"; + Assert.AreEqual(1, mutation.Fields.Count(f => f.Name.Value == expectedCreateMutationName)); + FieldDefinitionNode createMutation = mutation.Fields.First(f => f.Name.Value == expectedCreateMutationName); + Assert.AreEqual(expectedCreateMutationDescription, createMutation.Description.Value); + + // Name and Description validations for Update mutation + string expectedUpdateMutationName = $"update{expectedNames[i]}"; + string expectedUpdateMutationDescription = $"Updates a {expectedNames[i]}"; + Assert.AreEqual(1, mutation.Fields.Count(f => f.Name.Value == expectedUpdateMutationName)); + FieldDefinitionNode updateMutation = mutation.Fields.First(f => f.Name.Value == expectedUpdateMutationName); + Assert.AreEqual(expectedUpdateMutationDescription, updateMutation.Description.Value); + + // Name and Description validations for Delete mutation + string expectedDeleteMutationName = $"delete{expectedNames[i]}"; + string expectedDeleteMutationDescription = $"Delete a {expectedNames[i]}"; + Assert.AreEqual(1, mutation.Fields.Count(f => f.Name.Value == expectedDeleteMutationName)); + FieldDefinitionNode deleteMutation = mutation.Fields.First(f => f.Name.Value == expectedDeleteMutationName); + Assert.AreEqual(expectedDeleteMutationDescription, deleteMutation.Description.Value); + } + } + + /// + /// We assume that the user will provide a singular name for the entity. Users have the option of providing singular and + /// plural names for an entity in the config to have more control over the graphql schema generation. + /// When singular and plural names are specified by the user, these names will be used for generating the + /// queries and mutations in the schema. + /// When singular and plural names are not provided, the queries and mutations will be generated with the entity's name. + /// + /// This test validates a) Number of mutation fields generated b) Mutation field names c) Mutation field descriptions + /// when multiple create operations are enabled. + /// + /// + /// Type definition for the entity + /// Name of the entity + /// Singular name provided by the user + /// Plural name provided by the user + /// Expected name of the entity in the mutation. Used to construct the exact expected mutation names. + [DataTestMethod] + [DataRow(GraphQLTestHelpers.PEOPLE_GQL, new string[] { "People" }, null, null, new string[] { "People" }, new string[] { "Peoples" }, + DisplayName = "Mutation name and description validation for singular entity name with singular plural not defined - Multiple Create Operation enabled")] + [DataRow(GraphQLTestHelpers.PEOPLE_GQL, new string[] { "People" }, new string[] { "Person" }, new string[] { "People" }, new string[] { "Person" }, new string[] { "People" }, + DisplayName = "Mutation name and description validation for plural entity name with singular plural defined - Multiple Create Operation enabled")] + [DataRow(GraphQLTestHelpers.PEOPLE_GQL, new string[] { "People" }, new string[] { "Person" }, new string[] { "" }, new string[] { "Person" }, new string[] { "People" }, + DisplayName = "Mutation name and description validation for plural entity name with singular defined - Multiple Create Operation enabled")] + [DataRow(GraphQLTestHelpers.PERSON_GQL, new string[] { "Person" }, new string[] { "Person" }, new string[] { "People" }, new string[] { "Person" }, new string[] { "People" }, + DisplayName = "Mutation name and description validation for singular entity name with singular plural defined - Multiple Create Operation enabled")] + [DataRow(GraphQLTestHelpers.PERSON_GQL + GraphQLTestHelpers.BOOK_GQL, new string[] { "Person", "Book" }, new string[] { "Person", "Book" }, new string[] { "People", "Books" }, new string[] { "Person", "Book" }, new string[] { "People", "Books" }, + DisplayName = "Mutation name and description validation for multiple entities with singular, plural - Multiple Create Operation enabled")] + [DataRow(GraphQLTestHelpers.PERSON_GQL + GraphQLTestHelpers.BOOK_GQL, new string[] { "Person", "Book" }, null, null, new string[] { "Person", "Book" }, new string[] { "People", "Books" }, + DisplayName = "Mutation name and description validation for multiple entities with singular plural not defined - Multiple Create Operation enabled")] + public void ValidateMutationsAreCreatedWithRightNameWithMultipleCreateOperationsEnabled( + string gql, + string[] entityNames, + string[] singularNames, + string[] pluralNames, + string[] expectedNames, + string[] expectedCreateMultipleMutationNames) + { + Dictionary entityNameToEntity = new(); + Dictionary entityNameToDatabaseType = new(); + Dictionary entityPermissionsMap = GraphQLTestHelpers.CreateStubEntityPermissionsMap( + entityNames, + new EntityActionOperation[] { EntityActionOperation.Create, EntityActionOperation.Update, EntityActionOperation.Delete }, + new string[] { "anonymous", "authenticated" }); + DocumentNode root = Utf8GraphQLParser.Parse(gql); + + for (int i = 0; i < entityNames.Length; i++) + { + Entity entity = (singularNames is not null) + ? GraphQLTestHelpers.GenerateEntityWithSingularPlural(singularNames[i], pluralNames[i]) + : GraphQLTestHelpers.GenerateEntityWithSingularPlural(entityNames[i], entityNames[i].Pluralize()); + entityNameToEntity.TryAdd(entityNames[i], entity); + entityNameToDatabaseType.TryAdd(entityNames[i], DatabaseType.MSSQL); + } + DocumentNode mutationRoot = MutationBuilder.Build( + root, + entityNameToDatabaseType, + new(entityNameToEntity), + entityPermissionsMap: entityPermissionsMap, + IsMultipleCreateOperationEnabled: true); + + ObjectTypeDefinitionNode mutation = GetMutationNode(mutationRoot); + Assert.IsNotNull(mutation); + + // The permissions are setup for create, update and delete operations. + // So create, update and delete mutations should get generated. + // A Check to validate that the count of mutations generated is 4 - + // 1. 2 Create mutations (point/many) when db supports nested created, else 1. + // 2. 1 Update mutation + // 3. 1 Delete mutation + int totalExpectedMutations = 4 * entityNames.Length; Assert.AreEqual(totalExpectedMutations, mutation.Fields.Count); for (int i = 0; i < entityNames.Length; i++) @@ -1100,6 +1191,13 @@ string[] expectedNames FieldDefinitionNode createMutation = mutation.Fields.First(f => f.Name.Value == expectedCreateMutationName); Assert.AreEqual(expectedCreateMutationDescription, createMutation.Description.Value); + // Name and Description validations for CreateMultiple mutation + string expectedCreateMultipleMutationName = $"create{expectedCreateMultipleMutationNames[i]}"; + string expectedCreateMultipleMutationDescription = $"Creates multiple new {expectedCreateMultipleMutationNames[i]}"; + Assert.AreEqual(1, mutation.Fields.Count(f => f.Name.Value == expectedCreateMultipleMutationName)); + FieldDefinitionNode createMultipleMutation = mutation.Fields.First(f => f.Name.Value == expectedCreateMultipleMutationName); + Assert.AreEqual(expectedCreateMultipleMutationDescription, createMultipleMutation.Description.Value); + // Name and Description validations for Update mutation string expectedUpdateMutationName = $"update{expectedNames[i]}"; string expectedUpdateMutationDescription = $"Updates a {expectedNames[i]}"; From 75b06c0477e8bbfa03c1ebee4ff675b4cd067ac0 Mon Sep 17 00:00:00 2001 From: Shyam Sundar J Date: Tue, 26 Mar 2024 16:22:29 +0530 Subject: [PATCH 8/8] adds logic to throw exp when source is null; got missed during merge with latest main --- .../Services/MetadataProviders/SqlMetadataProvider.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs b/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs index 5e61bead48..3697e11220 100644 --- a/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs +++ b/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs @@ -641,6 +641,14 @@ protected void PopulateDatabaseObjectForEntity( EntitySourceType sourceType = GetEntitySourceType(entityName, entity); if (!EntityToDatabaseObject.ContainsKey(entityName)) { + if (entity.Source.Object is null) + { + throw new DataApiBuilderException( + message: $"The entity {entityName} does not have a valid source object.", + statusCode: HttpStatusCode.InternalServerError, + subStatusCode: DataApiBuilderException.SubStatusCodes.ConfigValidationError); + } + // Reuse the same Database object for multiple entities if they share the same source. if (!sourceObjects.TryGetValue(entity.Source.Object, out DatabaseObject? sourceObject)) {