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": {