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/schemas/dab.draft.schema.json b/schemas/dab.draft.schema.json
index 95172de225..39bde040c0 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."
+ },
+ "multiple-mutations": {
+ "type": "object",
+ "description": "Configuration properties for multiple mutation operations",
+ "additionalProperties": false,
+ "properties": {
+ "create":{
+ "type": "object",
+ "description": "Options for multiple create operations",
+ "additionalProperties": false,
+ "properties": {
+ "enabled": {
+ "type": "boolean",
+ "description": "Allow enabling/disabling multiple 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..4462f52c4d 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.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.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.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.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.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.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 multiple create enabled flag in the config file.
+ [DataTestMethod]
+ [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 };
+
+ 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 (isMultipleCreateEnabled is not CliBool.None)
+ {
+ args.Add("--graphql.multiple-create.enabled");
+ args.Add(isMultipleCreateEnabled.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 && isMultipleCreateEnabled is not CliBool.None)
+ {
+ 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.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.");
+ }
+ }
+
///
/// Test to verify adding a new Entity.
///
diff --git a/src/Cli.Tests/InitTests.cs b/src/Cli.Tests/InitTests.cs
index 31cff258a7..b8ea6bcb74 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.multiple-create.enabled flag option for different database types.
+ ///
+ /// 1. For database types other than MsSQL:
+ /// - 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.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.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.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;
+
+ 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,
+ multipleCreateOperationEnabled: isMultipleCreateEnabled);
+ }
+ 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,
+ multipleCreateOperationEnabled: isMultipleCreateEnabled);
+ }
+
+ VerifySettings verifySettings = new();
+ verifySettings.UseHashedParameters(databaseType, isMultipleCreateEnabled);
+ 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/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/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_0546bef37027a950.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_0546bef37027a950.verified.txt
new file mode 100644
index 0000000000..cbaaa45754
--- /dev/null
+++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_0546bef37027a950.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.VerifyCorrectConfigGenerationWithMultipleMutationOptions_0ac567dd32a2e8f5.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_0ac567dd32a2e8f5.verified.txt
new file mode 100644
index 0000000000..da7937d1d9
--- /dev/null
+++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_0ac567dd32a2e8f5.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.VerifyCorrectConfigGenerationWithMultipleMutationOptions_0c06949221514e77.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_0c06949221514e77.verified.txt
new file mode 100644
index 0000000000..62fc407842
--- /dev/null
+++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_0c06949221514e77.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,
+ MultipleMutationOptions: {
+ MultipleCreateOptions: {
+ 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.VerifyCorrectConfigGenerationWithMultipleMutationOptions_18667ab7db033e9d.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_18667ab7db033e9d.verified.txt
new file mode 100644
index 0000000000..3285438ab7
--- /dev/null
+++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_18667ab7db033e9d.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.VerifyCorrectConfigGenerationWithMultipleMutationOptions_2f42f44c328eb020.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_2f42f44c328eb020.verified.txt
new file mode 100644
index 0000000000..cbaaa45754
--- /dev/null
+++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_2f42f44c328eb020.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.VerifyCorrectConfigGenerationWithMultipleMutationOptions_3243d3f3441fdcc1.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_3243d3f3441fdcc1.verified.txt
new file mode 100644
index 0000000000..3285438ab7
--- /dev/null
+++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_3243d3f3441fdcc1.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.VerifyCorrectConfigGenerationWithMultipleMutationOptions_53350b8b47df2112.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_53350b8b47df2112.verified.txt
new file mode 100644
index 0000000000..a43e68277c
--- /dev/null
+++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_53350b8b47df2112.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.VerifyCorrectConfigGenerationWithMultipleMutationOptions_6584e0ec46b8a11d.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_6584e0ec46b8a11d.verified.txt
new file mode 100644
index 0000000000..9740a85a77
--- /dev/null
+++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_6584e0ec46b8a11d.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.VerifyCorrectConfigGenerationWithMultipleMutationOptions_81cc88db3d4eecfb.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_81cc88db3d4eecfb.verified.txt
new file mode 100644
index 0000000000..be47d537b2
--- /dev/null
+++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_81cc88db3d4eecfb.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,
+ MultipleMutationOptions: {
+ MultipleCreateOptions: {
+ 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.VerifyCorrectConfigGenerationWithMultipleMutationOptions_8ea187616dbb5577.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_8ea187616dbb5577.verified.txt
new file mode 100644
index 0000000000..673c21dae4
--- /dev/null
+++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_8ea187616dbb5577.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.VerifyCorrectConfigGenerationWithMultipleMutationOptions_905845c29560a3ef.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_905845c29560a3ef.verified.txt
new file mode 100644
index 0000000000..cbaaa45754
--- /dev/null
+++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_905845c29560a3ef.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.VerifyCorrectConfigGenerationWithMultipleMutationOptions_b2fd24fab5b80917.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_b2fd24fab5b80917.verified.txt
new file mode 100644
index 0000000000..9740a85a77
--- /dev/null
+++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_b2fd24fab5b80917.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.VerifyCorrectConfigGenerationWithMultipleMutationOptions_bd7cd088755287c9.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_bd7cd088755287c9.verified.txt
new file mode 100644
index 0000000000..9740a85a77
--- /dev/null
+++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_bd7cd088755287c9.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.VerifyCorrectConfigGenerationWithMultipleMutationOptions_d2eccba2f836b380.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_d2eccba2f836b380.verified.txt
new file mode 100644
index 0000000000..a43e68277c
--- /dev/null
+++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_d2eccba2f836b380.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.VerifyCorrectConfigGenerationWithMultipleMutationOptions_d463eed7fe5e4bbe.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_d463eed7fe5e4bbe.verified.txt
new file mode 100644
index 0000000000..673c21dae4
--- /dev/null
+++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_d463eed7fe5e4bbe.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.VerifyCorrectConfigGenerationWithMultipleMutationOptions_d5520dd5c33f7b8d.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_d5520dd5c33f7b8d.verified.txt
new file mode 100644
index 0000000000..a43e68277c
--- /dev/null
+++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_d5520dd5c33f7b8d.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.VerifyCorrectConfigGenerationWithMultipleMutationOptions_eab4a6010e602b59.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_eab4a6010e602b59.verified.txt
new file mode 100644
index 0000000000..3285438ab7
--- /dev/null
+++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_eab4a6010e602b59.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.VerifyCorrectConfigGenerationWithMultipleMutationOptions_ecaa688829b4030e.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_ecaa688829b4030e.verified.txt
new file mode 100644
index 0000000000..673c21dae4
--- /dev/null
+++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_ecaa688829b4030e.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..67b8ef5b62 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 multipleCreateOperationEnabled = CliBool.None,
string? config = null)
: base(config)
{
@@ -59,6 +60,7 @@ public InitOptions(
RestEnabled = restEnabled;
GraphQLEnabled = graphqlEnabled;
RestRequestBodyStrict = restRequestBodyStrict;
+ MultipleCreateOperationEnabled = multipleCreateOperationEnabled;
}
[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.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)
{
logger.LogInformation("{productName} {version}", PRODUCT_NAME, ProductInfo.GetProductVersion());
diff --git a/src/Cli/ConfigGenerator.cs b/src/Cli/ConfigGenerator.cs
index a63b6a19ed..8fe10e1749 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 isMultipleCreateEnabledForGraphQL;
+
+ // 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 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.MultipleCreateOperationEnabled is not CliBool.None)
+ {
+ _logger.LogWarning($"The option --graphql.multiple-create.enabled is not supported for the {dbType.ToString()} database type and will not be honored.");
+ }
+
+ MultipleMutationOptions? multipleMutationOptions = null;
+
+ // 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.MultipleCreateOperationEnabled is not CliBool.None)
+ {
+ isMultipleCreateEnabledForGraphQL = IsMultipleCreateOperationEnabled(options.MultipleCreateOperationEnabled);
+ multipleMutationOptions = new(multipleCreateOptions: new MultipleCreateOptions(enabled: isMultipleCreateEnabledForGraphQL));
+ }
+
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, MultipleMutationOptions: multipleMutationOptions),
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 multiple create operation is enabled or not based on the inputs from dab init command.
+ ///
+ /// Input value for --graphql.multiple-create.enabled option of the init command
+ /// True/False
+ private static bool IsMultipleCreateOperationEnabled(CliBool multipleCreateEnabledOptionValue)
+ {
+ return multipleCreateEnabledOptionValue 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..5f69531e9f 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 Multiple Mutation operations disabled by default
+ GraphQLRuntimeOptions graphQLRuntimeOptions = new();
+ MultipleMutationOptionsConverter multipleMutationOptionsConverter = options.GetConverter(typeof(MultipleMutationOptions)) as MultipleMutationOptionsConverter ??
+ throw new JsonException("Failed to get multiple 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 "multiple-mutations":
+ graphQLRuntimeOptions = graphQLRuntimeOptions with { MultipleMutationOptions = multipleMutationOptionsConverter.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.MultipleMutationOptions is not null)
+ {
+
+ MultipleMutationOptionsConverter multipleMutationOptionsConverter = options.GetConverter(typeof(MultipleMutationOptions)) as MultipleMutationOptionsConverter ??
+ throw new JsonException("Failed to get multiple mutation options converter");
+
+ multipleMutationOptionsConverter.Write(writer, value.MultipleMutationOptions, options);
+ }
+
writer.WriteEndObject();
}
}
diff --git a/src/Config/Converters/MultipleCreateOptionsConverter.cs b/src/Config/Converters/MultipleCreateOptionsConverter.cs
new file mode 100644
index 0000000000..5904b6b0c2
--- /dev/null
+++ b/src/Config/Converters/MultipleCreateOptionsConverter.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 multiple create operation options.
+ ///
+ internal class MultipleCreateOptionsConverter : JsonConverter
+ {
+ ///
+ public override MultipleCreateOptions? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ if (reader.TokenType == JsonTokenType.Null)
+ {
+ return null;
+ }
+
+ if (reader.TokenType is JsonTokenType.StartObject)
+ {
+ MultipleCreateOptions? multipleCreateOptions = 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)
+ {
+ multipleCreateOptions = new(reader.GetBoolean());
+ }
+
+ break;
+ default:
+ throw new JsonException($"Unexpected property {propertyName}");
+ }
+ }
+
+ return multipleCreateOptions;
+ }
+
+ throw new JsonException("Failed to read the GraphQL Multiple Create 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)
+ {
+ return;
+ }
+
+ writer.WritePropertyName("create");
+
+ writer.WriteStartObject();
+ writer.WritePropertyName("enabled");
+ writer.WriteBooleanValue(value.Enabled);
+ writer.WriteEndObject();
+ }
+ }
+}
diff --git a/src/Config/Converters/MultipleMutationOptionsConverter.cs b/src/Config/Converters/MultipleMutationOptionsConverter.cs
new file mode 100644
index 0000000000..fb943cad5b
--- /dev/null
+++ b/src/Config/Converters/MultipleMutationOptionsConverter.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 multiple mutation options.
+ ///
+ internal class MultipleMutationOptionsConverter : JsonConverter
+ {
+
+ private readonly MultipleCreateOptionsConverter _multipleCreateOptionsConverter;
+
+ public MultipleMutationOptionsConverter(JsonSerializerOptions options)
+ {
+ _multipleCreateOptionsConverter = options.GetConverter(typeof(MultipleCreateOptions)) as MultipleCreateOptionsConverter ??
+ throw new JsonException("Failed to get multiple create options converter");
+ }
+
+ ///
+ public override MultipleMutationOptions? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ if (reader.TokenType == JsonTokenType.Null)
+ {
+ return null;
+ }
+
+ if (reader.TokenType is JsonTokenType.StartObject)
+ {
+ MultipleMutationOptions? multipleMutationOptions = null;
+
+ while (reader.Read())
+ {
+ if (reader.TokenType == JsonTokenType.EndObject)
+ {
+ break;
+ }
+
+ string? propertyName = reader.GetString();
+ switch (propertyName)
+ {
+ case "create":
+ reader.Read();
+ MultipleCreateOptions? multipleCreateOptions = _multipleCreateOptionsConverter.Read(ref reader, typeToConvert, options);
+ if (multipleCreateOptions is not null)
+ {
+ multipleMutationOptions = new(multipleCreateOptions);
+ }
+
+ break;
+
+ default:
+ throw new JsonException($"Unexpected property {propertyName}");
+ }
+ }
+
+ return multipleMutationOptions;
+ }
+
+ throw new JsonException("Failed to read the GraphQL Multiple Mutation options");
+ }
+
+ ///
+ public override void Write(Utf8JsonWriter writer, MultipleMutationOptions? value, JsonSerializerOptions options)
+ {
+ // If the multiple mutation options is null, it is not written to the config file.
+ if (value is null)
+ {
+ return;
+ }
+
+ writer.WritePropertyName("multiple-mutations");
+
+ writer.WriteStartObject();
+
+ if (value.MultipleCreateOptions is not null)
+ {
+ _multipleCreateOptionsConverter.Write(writer, value.MultipleCreateOptions, options);
+ }
+
+ writer.WriteEndObject();
+ }
+ }
+}
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/Config/ObjectModel/GraphQLRuntimeOptions.cs b/src/Config/ObjectModel/GraphQLRuntimeOptions.cs
index 9969835cb2..24d8533e43 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,
+ 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/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/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/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/Config/RuntimeConfigLoader.cs b/src/Config/RuntimeConfigLoader.cs
index bbc452d029..f56db4bfa5 100644
--- a/src/Config/RuntimeConfigLoader.cs
+++ b/src/Config/RuntimeConfigLoader.cs
@@ -165,13 +165,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 MultipleCreateOptionsConverter());
+ options.Converters.Add(new MultipleMutationOptionsConverter(options));
options.Converters.Add(new DataSourceConverterFactory(replaceEnvVar));
if (replaceEnvVar)
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/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..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,18 +92,17 @@ 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)
{
- inputArgumentKeys = BaseSqlQueryStructure.GetSubArgumentNamesFromGQLMutArguments(MutationBuilder.INPUT_ARGUMENT_NAME, parameters);
+ inputArgumentKeys = BaseSqlQueryStructure.GetSubArgumentNamesFromGQLMutArguments(MutationBuilder.ITEM_INPUT_ARGUMENT_NAME, parameters);
}
else
{
@@ -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(
@@ -165,7 +163,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 +210,7 @@ private static async Task> HandleUpdateAsync(IDictionary> 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/Sql Query Structures/SqlInsertQueryStructure.cs b/src/Core/Resolvers/Sql Query Structures/SqlInsertQueryStructure.cs
index 67741ab8cc..2f4689a72a 100644
--- a/src/Core/Resolvers/Sql Query Structures/SqlInsertQueryStructure.cs
+++ b/src/Core/Resolvers/Sql Query Structures/SqlInsertQueryStructure.cs
@@ -47,7 +47,7 @@ HttpContext httpContext
sqlMetadataProvider,
authorizationResolver,
gQLFilterParser,
- GQLMutArgumentToDictParams(context, CreateMutationBuilder.INPUT_ARGUMENT_NAME, mutationParams),
+ GQLMutArgumentToDictParams(context, MutationBuilder.ITEM_INPUT_ARGUMENT_NAME, mutationParams),
httpContext)
{ }
diff --git a/src/Core/Resolvers/SqlMutationEngine.cs b/src/Core/Resolvers/SqlMutationEngine.cs
index 74356358cf..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.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/Core/Services/GraphQLSchemaCreator.cs b/src/Core/Services/GraphQLSchemaCreator.cs
index 9829dbf0f8..76ba3218c8 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
{
@@ -41,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.
@@ -59,6 +61,7 @@ public GraphQLSchemaCreator(
{
RuntimeConfig runtimeConfig = runtimeConfigProvider.GetConfig();
+ _isMultipleCreateOperationEnabled = runtimeConfig.IsMultipleCreateOperationEnabled();
_entities = runtimeConfig.Entities;
_queryEngineFactory = queryEngineFactory;
_mutationEngineFactory = mutationEngineFactory;
@@ -90,6 +93,7 @@ private ISchemaBuilder Parse(
.AddDirectiveType()
.AddDirectiveType()
.AddDirectiveType()
+ .AddDirectiveType()
.AddDirectiveType()
.AddDirectiveType()
// Add our custom scalar GraphQL types
@@ -135,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);
}
@@ -162,11 +166,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 +185,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 +211,13 @@ 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 +235,31 @@ private DocumentNode GenerateSqlGraphQLObjects(RuntimeEntities entities, Diction
}
}
+ // ReferencingFieldDirective is added to eventually mark the referencing fields in the input object types as optional. When multiple create operations are disabled
+ // the referencing fields should be required fields. Hence, ReferencingFieldDirective is added only when the multiple create operations are enabled.
+ if (_isMultipleCreateOperationEnabled)
+ {
+ // For all the fields in the object which hold a foreign key reference to any referenced entity, add a foreign key directive.
+ AddReferencingFieldDirective(entities, objectTypes);
+ }
+
// Pass two - Add the arguments to the many-to-* relationship fields
foreach ((string entityName, ObjectTypeDefinitionNode node) in objectTypes)
{
objectTypes[entityName] = QueryBuilder.AddQueryArgumentsForRelationships(node, inputObjects);
}
+ // Create ObjectTypeDefinitionNode for linking entities. These object definitions are not exposed in the schema
+ // but are used to generate the object definitions of directional linking entities for (source, target) and (target, source) entities.
+ // However, ObjectTypeDefinitionNode for linking entities are need only for multiple create operation. So, creating these only when multiple create operations are
+ // enabled.
+ if (_isMultipleCreateOperationEnabled)
+ {
+ Dictionary linkingObjectTypes = GenerateObjectDefinitionsForLinkingEntities();
+ GenerateSourceTargetLinkingObjectDefinitions(objectTypes, linkingObjectTypes);
+ }
+
+ // 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 +280,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 2d7ae916ff..a45a596983 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 339c53c4e0..f03216ba89 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;
@@ -28,6 +29,8 @@ namespace Azure.DataApiBuilder.Core.Services
public class MsSqlMetadataProvider :
SqlMetadataProvider
{
+ private RuntimeConfigProvider _runtimeConfigProvider;
+
public MsSqlMetadataProvider(
RuntimeConfigProvider runtimeConfigProvider,
IAbstractQueryManagerFactory queryManagerFactory,
@@ -36,6 +39,7 @@ public MsSqlMetadataProvider(
bool isValidateOnly = false)
: base(runtimeConfigProvider, queryManagerFactory, logger, dataSourceName, isValidateOnly)
{
+ _runtimeConfigProvider = runtimeConfigProvider;
}
public override string GetDefaultSchemaName()
@@ -220,6 +224,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 (!_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.
+ 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 0fd11a5974..3697e11220 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;
@@ -620,78 +624,84 @@ 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))
+ {
+ if (entity.Source.Object is null)
{
- 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);
- }
+ 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))
- {
- // parse source name into a tuple of (schemaName, databaseObjectName)
- (schemaName, dbObjectName) = ParseSchemaAndDbTableName(entity.Source.Object)!;
+ // 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)
+ (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);
}
}
@@ -727,10 +737,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
@@ -780,6 +791,24 @@ private void AddForeignKeysForRelationships(
referencingColumns: relationship.LinkingTargetFields,
referencedColumns: relationship.TargetFields,
relationshipData);
+
+ 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())
+ {
+ // 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)
{
@@ -837,6 +866,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.
@@ -934,6 +981,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.
@@ -944,55 +996,65 @@ 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);
+ }
+
+ try
+ {
+ await PopulateForeignKeyDefinitionAsync();
+ }
+ catch (Exception e)
+ {
+ HandleOrRecordException(e);
+ }
+ }
+
+ 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);
}
- }
-
- try
- {
- await PopulateForeignKeyDefinitionAsync();
}
catch (Exception 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..3b3614e74a 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;
@@ -26,6 +27,14 @@ 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 = new() { DatabaseType.MSSQL, DatabaseType.MySQL,
+ DatabaseType.DWSQL, DatabaseType.PostgreSQL, DatabaseType.CosmosDB_PostgreSQL };
+
public static bool IsModelType(ObjectTypeDefinitionNode objectTypeDefinitionNode)
{
string modelDirectiveName = ModelDirectiveType.DirectiveName;
@@ -61,6 +70,14 @@ public static bool IsBuiltInType(ITypeNode typeNode)
return builtInTypes.Contains(name);
}
+ ///
+ /// 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.
@@ -272,7 +289,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;
}
@@ -293,7 +310,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;
}
@@ -306,5 +323,78 @@ 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.
+ ///
+ /// 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..7581663b9e 100644
--- a/src/Service.GraphQLBuilder/Mutations/CreateMutationBuilder.cs
+++ b/src/Service.GraphQLBuilder/Mutations/CreateMutationBuilder.cs
@@ -15,25 +15,33 @@ 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.
+ /// Indicates whether multiple create operation is enabled
/// 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)
+ RuntimeEntities entities,
+ bool IsMultipleCreateOperationEnabled)
{
NameNode inputName = GenerateInputTypeName(name.Value);
@@ -42,31 +50,172 @@ 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, IsMultipleCreateOperationEnabled);
+ });
+
+ // 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 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, 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,
+ IsMultipleCreateOperationEnabled: IsMultipleCreateOperationEnabled);
+ }
+
+ // 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,
+ IsMultipleCreateOperationEnabled: IsMultipleCreateOperationEnabled);
+ });
+ // 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.
+ /// 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.
+ /// 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);
}
- 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,99 +230,193 @@ 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.
+ /// 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, 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 true;
}
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 true;
}
- 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
+ /// Indicates whether multiple create operation is enabled
+ private static InputValueDefinitionNode GenerateScalarInputType(NameNode name, FieldDefinitionNode fieldDefinition, bool isMultipleCreateOperationEnabled = false)
{
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;
+
+ if (isMultipleCreateOperationEnabled &&
+ DoesFieldHaveReferencingFieldDirective(fieldDefinition))
+ {
+ isFieldNullable = true;
+ }
+
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)
+ RuntimeEntities entities,
+ bool IsMultipleCreateOperationEnabled)
{
InputObjectTypeDefinitionNode node;
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,
+ IsMultipleCreateOperationEnabled);
}
else
{
node = inputs[inputTypeName];
}
- ITypeNode type = new NamedTypeNode(node.Name);
+ return GetComplexInputType(field, node, inputTypeName, IsMultipleCreateOperationEnabled);
+ }
+ ///
+ /// 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];
+ }
+
+ // For non-relational databases, multiple create operation is not supported. Hence, IsMultipleCreateOperationEnabled parameter is set to false.
+ return GetComplexInputType(field, node, inputTypeName, IsMultipleCreateOperationEnabled: false);
+ }
+
+ ///
+ /// 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.
+ /// Indicates whether multiple create operation is supported by the database type and is enabled through config file
+ ///
+ private static InputValueDefinitionNode GetComplexInputType(
+ FieldDefinitionNode relatedFieldDefinition,
+ InputObjectTypeDefinitionNode relatedFieldInputObjectTypeDefinition,
+ NameNode parentInputTypeName,
+ bool IsMultipleCreateOperationEnabled)
+ {
+ ITypeNode type = new NamedTypeNode(relatedFieldInputObjectTypeDefinition.Name);
+ if (IsMultipleCreateOperationEnabled)
+ {
+ 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 +426,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 +468,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.
///
@@ -231,8 +487,9 @@ private 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 FieldDefinitionNode Build(
+ public static IEnumerable Build(
NameNode name,
Dictionary inputs,
ObjectTypeDefinitionNode objectTypeDefinitionNode,
@@ -241,17 +498,34 @@ public static FieldDefinitionNode Build(
RuntimeEntities entities,
string dbEntityName,
string returnEntityName,
- IEnumerable? rolesAllowedForMutation = null)
+ IEnumerable? rolesAllowedForMutation = null,
+ bool IsMultipleCreateOperationEnabled = false)
{
+ 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,
+ IsMultipleCreateOperationEnabled: IsMultipleCreateOperationEnabled);
+ }
List fieldDefinitionNodeDirectives = new() { new(ModelDirectiveType.DirectiveName, new ArgumentNode(ModelDirectiveType.ModelNameArgument, dbEntityName)) };
@@ -264,22 +538,74 @@ 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);
+
+ // Multiple create node is created in the schema only when multiple create operation is enabled.
+ if (IsMultipleCreateOperationEnabled)
+ {
+ // 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..755a8a0d0e 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
@@ -30,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();
@@ -47,19 +50,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);
}
@@ -75,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);
}
@@ -109,6 +110,7 @@ public static DocumentNode Build(
///
///
///
+ /// Indicates whether multiple create operation is enabled
///
private static void AddMutations(
string dbEntityName,
@@ -121,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 +133,18 @@ 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,
+ IsMultipleCreateOperationEnabled);
+ 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/CreateMutationAuthorizationTests.cs b/src/Service.Tests/Authorization/GraphQL/CreateMutationAuthorizationTests.cs
new file mode 100644
index 0000000000..1cc0e9f1b4
--- /dev/null
+++ b/src/Service.Tests/Authorization/GraphQL/CreateMutationAuthorizationTests.cs
@@ -0,0 +1,460 @@
+// 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]
+ [Ignore]
+ 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]
+ [Ignore]
+ 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]
+ [Ignore]
+ 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]
+ [Ignore]
+ 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 c2ffdcc90e..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" +
@@ -69,7 +63,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()
@@ -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/Configuration/ConfigurationTests.cs b/src/Service.Tests/Configuration/ConfigurationTests.cs
index 3cfb150287..d1d7f313e0 100644
--- a/src/Service.Tests/Configuration/ConfigurationTests.cs
+++ b/src/Service.Tests/Configuration/ConfigurationTests.cs
@@ -71,6 +71,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.
///
@@ -1806,6 +1843,119 @@ public async Task TestSPRestDefaultsForManuallyConstructedConfigs(
}
}
+ ///
+ /// Validates that deserialization of config file is successful for the following scenarios:
+ /// 1. Multiple Mutations section is null
+ /// {
+ /// "multiple-mutations": null
+ /// }
+ ///
+ /// 2. Multiple Mutations section is empty.
+ /// {
+ /// "multiple-mutations": {}
+ /// }
+ ///
+ /// 3. Create field within Multiple Mutation section is null.
+ /// {
+ /// "multiple-mutations": {
+ /// "create": null
+ /// }
+ /// }
+ ///
+ /// 4. Create field within Multiple Mutation section is empty.
+ /// {
+ /// "multiple-mutations": {
+ /// "create": {}
+ /// }
+ /// }
+ ///
+ /// For all the above mentioned scenarios, the expected value for MultipleMutationOptions field is null.
+ ///
+ /// Base Config Json string.
+ [DataTestMethod]
+ [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.MultipleMutationOptions);
+ }
+
+ ///
+ /// 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:
+ ///
+ /// "graphql": {
+ /// "path": "/graphql",
+ /// "allow-introspection": true
+ /// }
+ ///
+ /// 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 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).
+ 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
@@ -2064,6 +2214,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.
@@ -3358,6 +3683,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/DatabaseSchema-MsSql.sql b/src/Service.Tests/DatabaseSchema-MsSql.sql
index afbc9b1254..3633993d61 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..db0d992113
--- /dev/null
+++ b/src/Service.Tests/GraphQLBuilder/MultipleMutationBuilderTests.cs
@@ -0,0 +1,451 @@
+// 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);
+ FileSystemRuntimeConfigLoader configPath = TestHelper.GetRuntimeConfigLoader();
+ 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;
+ }
+
+ ///
+ /// 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..57ac444ec7 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()
@@ -1052,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
@@ -1061,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,
@@ -1104,7 +1067,8 @@ string[] expectedNames
root,
entityNameToDatabaseType,
new(entityNameToEntity),
- entityPermissionsMap: entityPermissionsMap
+ entityPermissionsMap: entityPermissionsMap,
+ IsMultipleCreateOperationEnabled: false
);
ObjectTypeDefinitionNode mutation = GetMutationNode(mutationRoot);
@@ -1112,8 +1076,12 @@ 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 3 -
+ // 1. 1 Create mutation
+ // 2. 1 Update mutation
+ // 3. 1 Delete mutation
+ int totalExpectedMutations = 3 * entityNames.Length;
+ Assert.AreEqual(totalExpectedMutations, mutation.Fields.Count);
for (int i = 0; i < entityNames.Length; i++)
{
@@ -1140,6 +1108,112 @@ string[] expectedNames
}
}
+ ///
+ /// 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++)
+ {
+ // 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 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]}";
+ 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);
+ }
+ }
+
///
/// Tests the GraphQL schema builder method MutationBuilder.Build()'s behavior when processing stored procedure entity configuration
/// which may explicitly define the field type(query/mutation) of the entity.
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.
diff --git a/src/Service.Tests/Multidab-config.MsSql.json b/src/Service.Tests/Multidab-config.MsSql.json
index 00aaac2ed2..a428c0a27b 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,
+ "multiple-mutations": {
+ "create": {
+ "enabled": true
+ }
+ }
},
"host": {
"cors": {
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/TestHelper.cs b/src/Service.Tests/TestHelper.cs
index b1a1137b62..a8d152651c 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 multiple mutations section as null.
+ ///
+ public const string BASE_CONFIG_NULL_MULTIPLE_MUTATIONS_FIELD =
+ RUNTIME_REST_GRAPHQL_OPTIONS_CONFIG_JSON +
+ @"
+ ""multiple-mutations"": null
+ }," +
+ HOST_AND_ENTITY_OPTIONS_CONFIG_JSON;
+
+ ///
+ /// A minimal valid config json with an empty multiple mutations section.
+ ///
+ public const string BASE_CONFIG_EMPTY_MULTIPLE_MUTATIONS_FIELD =
+
+ RUNTIME_REST_GRAPHQL_OPTIONS_CONFIG_JSON +
+ @"
+ ""multiple-mutations"": {}
+ }," +
+ HOST_AND_ENTITY_OPTIONS_CONFIG_JSON;
+
+ ///
+ /// A minimal valid config json with the create field within multiple mutation as null.
+ ///
+ public const string BASE_CONFIG_NULL_MULTIPLE_CREATE_FIELD =
+
+ RUNTIME_REST_GRAPHQL_OPTIONS_CONFIG_JSON +
+ @"
+ ""multiple-mutations"": {
+ ""create"": null
+ }
+ }," +
+ HOST_AND_ENTITY_OPTIONS_CONFIG_JSON;
+
+ ///
+ /// A minimal valid config json with an empty create field within multiple mutation.
+ ///
+ public const string BASE_CONFIG_EMPTY_MULTIPLE_CREATE_FIELD =
+
+ RUNTIME_REST_GRAPHQL_OPTIONS_CONFIG_JSON +
+ @"
+ ""multiple-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..0d754263bb 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,
+ ""multiple-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..1743052c28 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,
+ "multiple-mutations": {
+ "create": {
+ "enabled": true
+ }
+ }
},
"host": {
"cors": {
@@ -319,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": [
@@ -391,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": [
@@ -1380,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": {